Code.gs
const YT_CFG = Object.freeze({
API_BASE: 'https://www.googleapis.com/youtube/v3',
API_KEY_PROP: 'YT_API_KEY',
STATE_PROP: 'YT_COMMENT_COLLECTOR_STATE',
LAST_RESULT_PROP: 'YT_COMMENT_COLLECTOR_LAST_RESULT',
CANCEL_REQUEST_PROP: 'YT_COMMENT_COLLECTOR_CANCEL_REQUEST',
OUTPUT_SHEET_PREFIX: '시트',
INTERNAL_SHEET: '_댓글수집_내부',
LEGACY_LOG_SHEET: '수집로그',
CONTINUE_HANDLER: 'continueCommentCollection',
STRUCTURED_ERROR_PREFIX: 'YT_STRUCTURED_ERROR::',
STATE_VERSION: 4,
// Apps Script의 일반 실행 제한 전에 상태를 저장하고 다음 실행으로 넘깁니다.
MAX_RUN_MS: 4 * 60 * 1000 + 30 * 1000,
CONTINUE_AFTER_MS: 60 * 1000,
MIN_LIMIT: 1,
MAX_LIMIT: 50000,
HEADERS: [
'번호',
'채널명',
'영상 제목',
'댓글 작성자',
'댓글 내용',
'좋아요 수',
'작성 시각',
'글자수'
],
// 숨김 내부 시트의 열 구조입니다.
INTERNAL_HEADERS: [
'comment_id',
'parent_comment_id',
'display_number',
'comment_type',
'reply_status',
'next_reply_index',
'total_reply_count',
'reply_page_token'
],
INTERNAL_COL: Object.freeze({
COMMENT_ID: 1,
PARENT_ID: 2,
DISPLAY_NUMBER: 3,
COMMENT_TYPE: 4,
REPLY_STATUS: 5,
NEXT_REPLY_INDEX: 6,
TOTAL_REPLY_COUNT: 7,
REPLY_PAGE_TOKEN: 8
})
});
/**
* 스프레드시트를 열 때 사용자 메뉴를 만듭니다.
*/
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('YouTube 댓글 수집')
.addItem('사이드바 열기', 'showYouTubeCommentSidebar')
.addSeparator()
.addItem('중단 지점부터 실행', 'continueCommentCollection')
.addItem('진행 중인 수집 취소', 'cancelCommentCollection')
.addToUi();
}
/**
* 오른쪽 사이드바를 엽니다.
*/
function showYouTubeCommentSidebar() {
const html = HtmlService
.createHtmlOutputFromFile('Sidebar')
.setTitle('YouTube 댓글 수집기');
SpreadsheetApp.getUi().showSidebar(html);
}
/**
* 사이드바 초기 상태를 반환합니다.
*/
function getSidebarState() {
const props = getUserProperties_();
const apiKey = props.getProperty(YT_CFG.API_KEY_PROP) || '';
const activeState = loadState_();
const lastResult = loadLastResult_();
const cancelRequested =
props.getProperty(YT_CFG.CANCEL_REQUEST_PROP) === 'true';
const progress = activeState
? buildProgressForClient_(activeState, 'RUNNING')
: lastResult
? buildProgressForClient_(lastResult, lastResult.status || 'COMPLETED')
: null;
if (progress && activeState && cancelRequested) {
progress.stage = '취소 처리 중';
progress.message = '현재 실행 구간이 끝나는 즉시 안전하게 중단됩니다.';
}
return {
apiKeyConfigured: Boolean(apiKey),
apiKeyMask: apiKey ? maskApiKey_(apiKey) : '',
running: Boolean(activeState),
cancelRequested: cancelRequested,
progress: progress
};
}
/**
* API 키를 실제 YouTube Data API 요청으로 검증한 뒤 사용자별 속성에 저장합니다.
*/
function saveApiKey(apiKey) {
const cleanedKey = String(apiKey || '').trim();
if (!cleanedKey) {
throwStructuredError_(buildErrorInfo_(
'API_KEY_REQUIRED',
'API 키를 입력해야 합니다.',
'Google Cloud에서 발급한 YouTube Data API 키가 입력되지 않았습니다.',
['API 키 입력란이 비어 있습니다.'],
[
'Google Cloud Console에서 API 키를 발급합니다.',
'API 키 전체를 사이드바 입력란에 붙여 넣습니다.',
'‘API 키 확인 후 저장’을 누릅니다.'
],
{
helpLabel: 'API 키 관리 화면 열기',
helpUrl: 'https://console.cloud.google.com/apis/credentials',
focusTarget: 'apiKey',
actionLabel: 'API 키 입력'
}
));
}
if (cleanedKey.length < 20) {
throwStructuredError_(buildErrorInfo_(
'API_KEY_FORMAT_INVALID',
'API 키 형식을 확인해야 합니다.',
'입력한 문자열이 일반적인 Google API 키 길이보다 짧습니다.',
[
'API 키 일부만 복사했을 수 있습니다.',
'프로젝트 ID나 다른 값을 입력했을 수 있습니다.'
],
[
'Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.',
'키 유형이 ‘API 키’인지 확인합니다.',
'API 키 값을 처음부터 끝까지 다시 복사하여 저장합니다.'
],
{
helpLabel: 'API 키 관리 화면 열기',
helpUrl: 'https://console.cloud.google.com/apis/credentials',
focusTarget: 'apiKey',
actionLabel: 'API 키 다시 입력'
}
));
}
validateApiKey_(cleanedKey);
const props = getUserProperties_();
props.setProperty(YT_CFG.API_KEY_PROP, cleanedKey);
// 이전 API 키 오류 안내가 새 키 저장 뒤 다시 나타나지 않도록 정리합니다.
const previous = loadLastResult_();
if (previous && previous.status === 'ERROR') {
props.deleteProperty(YT_CFG.LAST_RESULT_PROP);
}
return getSidebarState();
}
/**
* 저장된 API 키를 삭제합니다.
*/
function deleteApiKey() {
if (loadState_()) {
throwStructuredError_(buildErrorInfo_(
'API_KEY_DELETE_BLOCKED',
'수집 중에는 API 키를 삭제할 수 없습니다.',
'현재 댓글 수집 작업이 저장된 API 키를 사용하고 있습니다.',
['진행 중인 작업이 존재합니다.'],
[
'현재 수집을 완료하거나 취소합니다.',
'수집 상태가 종료된 뒤 API 키를 삭제합니다.'
],
{ actionLabel: '확인' }
));
}
const props = getUserProperties_();
props.deleteProperty(YT_CFG.API_KEY_PROP);
props.deleteProperty(YT_CFG.LAST_RESULT_PROP);
return getSidebarState();
}
/**
* 사이드바 입력값으로 새 수집 작업을 준비합니다.
* 실제 수집은 runCollectionNow 또는 시간 기반 트리거가 수행합니다.
*/
function startCollectionFromSidebar(formData) {
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
const apiKey = getApiKey_();
const input = normalizeSidebarInput_(formData);
const ss = SpreadsheetApp.getActiveSpreadsheet();
if (!ss) {
throwStructuredError_(buildErrorInfo_(
'SPREADSHEET_NOT_FOUND',
'댓글을 저장할 스프레드시트를 찾지 못했습니다.',
'현재 Apps Script가 스프레드시트와 연결된 상태인지 확인해야 합니다.',
[
'독립 실행형 Apps Script 프로젝트에서 코드를 실행했을 수 있습니다.',
'스프레드시트가 닫히거나 접근 권한이 변경되었을 수 있습니다.'
],
[
'댓글을 저장할 Google 스프레드시트를 엽니다.',
'‘확장 프로그램 → Apps Script’에서 이 코드를 실행합니다.',
'스프레드시트를 새로고침하고 사이드바를 다시 엽니다.'
],
{ actionLabel: '확인' }
));
}
// 영상과 API 키가 정상인지 먼저 확인합니다.
const videoId = extractVideoId_(input.videoUrl);
const metadata = getVideoMetadataWithKey_(videoId, apiKey);
deleteContinuationTriggers_();
const props = getUserProperties_();
props.deleteProperty(YT_CFG.STATE_PROP);
props.deleteProperty(YT_CFG.LAST_RESULT_PROP);
props.deleteProperty(YT_CFG.CANCEL_REQUEST_PROP);
// 새 수집은 기존 결과를 지우지 않고 새 결과 시트에 기록합니다.
const outputSheet = createNextOutputSheet_(ss);
ensureInternalSheet_(ss);
resetInternalData_(ss);
deleteLegacyLogSheet_(ss);
const state = {
version: YT_CFG.STATE_VERSION,
status: 'RUNNING',
spreadsheetId: ss.getId(),
outputSheetId: outputSheet.getSheetId(),
outputSheetName: outputSheet.getName(),
videoId: videoId,
videoTitle: metadata.title,
videoChannelId: metadata.channelId,
videoChannelTitle: metadata.channelTitle,
reportedCommentCount: metadata.commentCount,
targetTopLevelCount: input.limit,
includeReplies: input.includeReplies,
nextThreadPageToken: '',
threadDone: false,
currentReplyParentId: '',
nextReplyPageToken: '',
topCount: 0,
replyCount: 0,
threadPages: 0,
replyPages: 0,
apiCalls: 1,
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
message: outputSheet.getName() + '에서 수집을 시작했습니다.',
error: null
};
saveState_(state);
// 사이드바가 닫혀도 수집이 이어지도록 예비 트리거를 만듭니다.
scheduleContinuation_();
return getSidebarState();
} finally {
lock.releaseLock();
}
}
/**
* 사이드바가 첫 실행 구간을 즉시 시작할 때 호출합니다.
*/
function runCollectionNow() {
continueCommentCollection();
return getSidebarState();
}
/**
* 저장된 상태에서 댓글 수집을 계속합니다.
*/
function continueCommentCollection() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(5000)) {
return;
}
let ss = null;
let state = null;
let commentsSheet = null;
try {
state = loadState_();
if (!state) {
return;
}
validateStateVersion_(state);
normalizeState_(state);
ss = SpreadsheetApp.openById(state.spreadsheetId);
ensureInternalSheet_(ss);
deleteLegacyLogSheet_(ss);
commentsSheet = getOutputSheetForState_(ss, state);
const internalSheet = ss.getSheetByName(YT_CFG.INTERNAL_SHEET);
const existingIds = getExistingCommentIds_(internalSheet);
const existingCounts = getExistingCounts_(internalSheet);
// 시트 기록 직후 상태 저장 전에 실행이 끊긴 상황을 보정합니다.
state.topCount = existingCounts.top;
state.replyCount = existingCounts.reply;
const runStartedAt = Date.now();
while (Date.now() - runStartedAt < YT_CFG.MAX_RUN_MS) {
if (isCancelRequested_()) {
finalizeCancellation_(ss, commentsSheet, state);
return;
}
// 처리 중이던 답글 페이지가 있으면 먼저 이어서 처리합니다.
if (state.currentReplyParentId) {
processReplyPage_(state, commentsSheet, internalSheet, existingIds);
saveState_(state);
continue;
}
// 내부 시트에서 답글 수집이 필요한 최상위 댓글을 하나 선택합니다.
const pendingReply = claimNextPendingReply_(internalSheet);
if (pendingReply) {
state.currentReplyParentId = pendingReply.commentId;
state.nextReplyPageToken = pendingReply.pageToken || '';
saveState_(state);
continue;
}
// 답글 대기 항목이 없을 때 다음 최상위 댓글 페이지를 가져옵니다.
if (!state.threadDone) {
processThreadPage_(state, commentsSheet, internalSheet, existingIds);
saveState_(state);
continue;
}
finishCollection_(ss, commentsSheet, state);
return;
}
state.updatedAt = new Date().toISOString();
state.message = '실행 시간을 고려하여 진행 상태를 저장했습니다.';
saveState_(state);
scheduleContinuation_();
} catch (error) {
deleteContinuationTriggers_();
const errorInfo = normalizeErrorInfo_(error, '댓글 수집');
if (errorInfo.code === 'API_KEY_INVALID') {
getUserProperties_().deleteProperty(YT_CFG.API_KEY_PROP);
}
if (commentsSheet) {
try {
finalizeCommentsSheet_(commentsSheet);
} catch (formatError) {
console.error(formatError);
}
}
if (state) {
state.status = 'ERROR';
state.updatedAt = new Date().toISOString();
state.message = errorInfo.summary;
state.error = errorInfo;
saveLastResult_(state, 'ERROR', errorInfo.summary, errorInfo);
}
const props = getUserProperties_();
props.deleteProperty(YT_CFG.STATE_PROP);
props.deleteProperty(YT_CFG.CANCEL_REQUEST_PROP);
console.error(error);
throwStructuredError_(errorInfo);
} finally {
lock.releaseLock();
}
}
/**
* 진행 중인 수집을 취소합니다.
*/
function cancelCommentCollection() {
const props = getUserProperties_();
const state = loadState_();
if (!state) {
props.deleteProperty(YT_CFG.CANCEL_REQUEST_PROP);
deleteContinuationTriggers_();
return getSidebarState();
}
props.setProperty(YT_CFG.CANCEL_REQUEST_PROP, 'true');
deleteContinuationTriggers_();
const lock = LockService.getScriptLock();
// 다른 수집 실행이 없다면 이 호출에서 즉시 취소를 마무리합니다.
if (lock.tryLock(200)) {
try {
const latestState = loadState_();
if (latestState) {
const ss = SpreadsheetApp.openById(latestState.spreadsheetId);
ensureInternalSheet_(ss);
const commentsSheet = getOutputSheetForState_(ss, latestState);
finalizeCancellation_(ss, commentsSheet, latestState);
}
} finally {
lock.releaseLock();
}
}
return getSidebarState();
}
/**
* 최상위 댓글 스레드 한 페이지를 처리합니다.
*/
function processThreadPage_(state, commentsSheet, internalSheet, existingIds) {
const remaining = state.targetTopLevelCount - state.topCount;
if (remaining <= 0) {
state.threadDone = true;
state.nextThreadPageToken = '';
return;
}
const params = {
part: state.includeReplies ? 'snippet,replies' : 'snippet',
videoId: state.videoId,
maxResults: Math.min(100, remaining),
textFormat: 'plainText'
};
if (state.nextThreadPageToken) {
params.pageToken = state.nextThreadPageToken;
}
const data = youtubeGet_('commentThreads', params);
state.apiCalls += 1;
state.threadPages += 1;
const commentRows = [];
const internalRows = [];
const batchIds = new Set();
const items = Array.isArray(data.items) ? data.items : [];
let topAdded = 0;
let replyAdded = 0;
let nextTopNumber = state.topCount + 1;
items.forEach(function(thread) {
if (state.topCount + topAdded >= state.targetTopLevelCount) {
return;
}
const threadSnippet = thread.snippet || {};
const topComment = threadSnippet.topLevelComment;
if (!topComment || !topComment.id) {
return;
}
const topCommentId = String(topComment.id);
if (existingIds.has(topCommentId) || batchIds.has(topCommentId)) {
return;
}
const topNumber = String(nextTopNumber);
nextTopNumber += 1;
const totalReplyCount = Number(threadSnippet.totalReplyCount || 0);
let replyStatus = 'DONE';
let nextReplyIndex = 1;
commentRows.push(
commentToOutputRow_(state, topComment, topNumber)
);
batchIds.add(topCommentId);
topAdded += 1;
const inlineReplies =
thread.replies && Array.isArray(thread.replies.comments)
? thread.replies.comments
: [];
if (state.includeReplies && totalReplyCount > 0) {
if (inlineReplies.length === totalReplyCount) {
inlineReplies.forEach(function(reply) {
if (!reply || !reply.id) {
return;
}
const replyId = String(reply.id);
if (existingIds.has(replyId) || batchIds.has(replyId)) {
return;
}
const displayNumber = topNumber + '_' + nextReplyIndex;
nextReplyIndex += 1;
commentRows.push(
commentToOutputRow_(state, reply, displayNumber)
);
internalRows.push(
buildInternalRow_(
replyId,
topCommentId,
displayNumber,
'reply',
'',
'',
'',
''
)
);
batchIds.add(replyId);
replyAdded += 1;
});
} else {
// commentThreads.list의 replies에는 전체 답글이 보장되지 않으므로
// comments.list로 전체 답글을 다시 가져옵니다.
replyStatus = 'PENDING';
nextReplyIndex = 1;
}
}
internalRows.push(
buildInternalRow_(
topCommentId,
'',
topNumber,
'topLevel',
replyStatus,
nextReplyIndex,
totalReplyCount,
''
)
);
});
appendCollectedRecords_(
commentsSheet,
internalSheet,
commentRows,
internalRows
);
batchIds.forEach(function(id) {
existingIds.add(id);
});
state.topCount += topAdded;
state.replyCount += replyAdded;
const reachedTarget = state.topCount >= state.targetTopLevelCount;
const hasNextPage = Boolean(data.nextPageToken);
state.nextThreadPageToken =
!reachedTarget && hasNextPage
? data.nextPageToken
: '';
state.threadDone = reachedTarget || !hasNextPage;
state.updatedAt = new Date().toISOString();
state.message = buildServerProgressMessage_(state);
}
/**
* 특정 최상위 댓글의 답글 한 페이지를 처리합니다.
*/
function processReplyPage_(state, commentsSheet, internalSheet, existingIds) {
const topRecord = findInternalRecordByCommentId_(
internalSheet,
state.currentReplyParentId
);
if (!topRecord) {
// 내부 상태가 사라진 경우 현재 항목을 건너뛰고 다음 대기 항목을 찾습니다.
state.currentReplyParentId = '';
state.nextReplyPageToken = '';
return;
}
const params = {
part: 'snippet',
parentId: state.currentReplyParentId,
maxResults: 100,
textFormat: 'plainText'
};
const pageToken =
state.nextReplyPageToken || topRecord.replyPageToken || '';
if (pageToken) {
params.pageToken = pageToken;
}
const data = youtubeGet_('comments', params);
state.apiCalls += 1;
state.replyPages += 1;
const items = Array.isArray(data.items) ? data.items : [];
const commentRows = [];
const internalRows = [];
const batchIds = new Set();
let nextReplyIndex = Number(topRecord.nextReplyIndex || 1);
let replyAdded = 0;
items.forEach(function(reply) {
if (!reply || !reply.id) {
return;
}
const replyId = String(reply.id);
if (existingIds.has(replyId) || batchIds.has(replyId)) {
return;
}
const displayNumber =
String(topRecord.displayNumber) + '_' + nextReplyIndex;
nextReplyIndex += 1;
commentRows.push(
commentToOutputRow_(state, reply, displayNumber)
);
internalRows.push(
buildInternalRow_(
replyId,
state.currentReplyParentId,
displayNumber,
'reply',
'',
'',
'',
''
)
);
batchIds.add(replyId);
replyAdded += 1;
});
appendCollectedRecords_(
commentsSheet,
internalSheet,
commentRows,
internalRows
);
batchIds.forEach(function(id) {
existingIds.add(id);
});
state.replyCount += replyAdded;
const nextPageToken = data.nextPageToken || '';
const nextStatus = nextPageToken ? 'IN_PROGRESS' : 'DONE';
updateTopReplyProgress_(
internalSheet,
topRecord.rowNumber,
nextStatus,
nextReplyIndex,
nextPageToken
);
if (nextPageToken) {
state.nextReplyPageToken = nextPageToken;
} else {
state.currentReplyParentId = '';
state.nextReplyPageToken = '';
}
state.updatedAt = new Date().toISOString();
state.message = buildServerProgressMessage_(state);
}
/**
* 내부 시트에서 답글 수집 대기 중인 최상위 댓글 하나를 선택합니다.
*/
function claimNextPendingReply_(internalSheet) {
const statusRange = internalSheet.getRange(
2,
YT_CFG.INTERNAL_COL.REPLY_STATUS,
Math.max(internalSheet.getLastRow() - 1, 1),
1
);
let found = statusRange
.createTextFinder('IN_PROGRESS')
.matchEntireCell(true)
.useRegularExpression(false)
.findNext();
if (!found) {
found = statusRange
.createTextFinder('PENDING')
.matchEntireCell(true)
.useRegularExpression(false)
.findNext();
}
if (!found) {
return null;
}
const rowNumber = found.getRow();
const values = internalSheet
.getRange(rowNumber, 1, 1, YT_CFG.INTERNAL_HEADERS.length)
.getValues()[0];
if (values[YT_CFG.INTERNAL_COL.REPLY_STATUS - 1] === 'PENDING') {
internalSheet
.getRange(rowNumber, YT_CFG.INTERNAL_COL.REPLY_STATUS)
.setValue('IN_PROGRESS');
}
return {
rowNumber: rowNumber,
commentId: String(values[YT_CFG.INTERNAL_COL.COMMENT_ID - 1] || ''),
displayNumber: String(
values[YT_CFG.INTERNAL_COL.DISPLAY_NUMBER - 1] || ''
),
nextReplyIndex: Number(
values[YT_CFG.INTERNAL_COL.NEXT_REPLY_INDEX - 1] || 1
),
pageToken: String(
values[YT_CFG.INTERNAL_COL.REPLY_PAGE_TOKEN - 1] || ''
)
};
}
/**
* 댓글 1건을 사용자가 보는 8열 행으로 변환합니다.
*/
function commentToOutputRow_(state, comment, displayNumber) {
const snippet = (comment && comment.snippet) || {};
const text = String(snippet.textDisplay || '');
return [
String(displayNumber),
safeCellText_(state.videoChannelTitle || ''),
safeCellText_(state.videoTitle || ''),
safeCellText_(snippet.authorDisplayName || ''),
safeCellText_(text),
Number(snippet.likeCount || 0),
apiDateToSheetValue_(snippet.publishedAt),
calculateCommentBytes_(text)
];
}
/**
* 숨김 내부 시트의 행을 만듭니다.
*/
function buildInternalRow_(
commentId,
parentCommentId,
displayNumber,
commentType,
replyStatus,
nextReplyIndex,
totalReplyCount,
replyPageToken
) {
return [
safeCellText_(commentId || ''),
safeCellText_(parentCommentId || ''),
String(displayNumber || ''),
String(commentType || ''),
String(replyStatus || ''),
nextReplyIndex === '' ? '' : Number(nextReplyIndex || 1),
totalReplyCount === '' ? '' : Number(totalReplyCount || 0),
safeCellText_(replyPageToken || '')
];
}
/**
* 댓글 행과 내부 관리 행을 각각 일괄 기록합니다.
*/
function appendCollectedRecords_(
commentsSheet,
internalSheet,
commentRows,
internalRows
) {
if (commentRows.length !== internalRows.length) {
throwStructuredError_(buildErrorInfo_(
'INTERNAL_ROW_MISMATCH',
'댓글 저장 구조를 확인해야 합니다.',
'댓글 행과 내부 관리 행의 개수가 일치하지 않습니다.',
['수집 상태를 기록하는 내부 데이터가 일시적으로 어긋났습니다.'],
[
'현재 수집을 취소합니다.',
'사이드바에서 같은 영상을 새로 수집합니다.'
],
{ actionLabel: '확인' }
));
}
if (commentRows.length === 0) {
return;
}
commentsSheet
.getRange(
commentsSheet.getLastRow() + 1,
1,
commentRows.length,
YT_CFG.HEADERS.length
)
.setValues(commentRows);
internalSheet
.getRange(
internalSheet.getLastRow() + 1,
1,
internalRows.length,
YT_CFG.INTERNAL_HEADERS.length
)
.setValues(internalRows);
}
/**
* 내부 시트에서 특정 댓글 ID의 관리 행을 찾습니다.
*/
function findInternalRecordByCommentId_(internalSheet, commentId) {
const lastRow = internalSheet.getLastRow();
if (lastRow <= 1) {
return null;
}
const found = internalSheet
.getRange(2, YT_CFG.INTERNAL_COL.COMMENT_ID, lastRow - 1, 1)
.createTextFinder(String(commentId || ''))
.matchEntireCell(true)
.useRegularExpression(false)
.findNext();
if (!found) {
return null;
}
const rowNumber = found.getRow();
const values = internalSheet
.getRange(rowNumber, 1, 1, YT_CFG.INTERNAL_HEADERS.length)
.getValues()[0];
return {
rowNumber: rowNumber,
commentId: String(values[YT_CFG.INTERNAL_COL.COMMENT_ID - 1] || ''),
displayNumber: String(
values[YT_CFG.INTERNAL_COL.DISPLAY_NUMBER - 1] || ''
),
replyStatus: String(
values[YT_CFG.INTERNAL_COL.REPLY_STATUS - 1] || ''
),
nextReplyIndex: Number(
values[YT_CFG.INTERNAL_COL.NEXT_REPLY_INDEX - 1] || 1
),
replyPageToken: String(
values[YT_CFG.INTERNAL_COL.REPLY_PAGE_TOKEN - 1] || ''
)
};
}
/**
* 최상위 댓글의 답글 수집 진행 정보를 갱신합니다.
*/
function updateTopReplyProgress_(
internalSheet,
rowNumber,
status,
nextReplyIndex,
pageToken
) {
internalSheet
.getRange(rowNumber, YT_CFG.INTERNAL_COL.REPLY_STATUS, 1, 4)
.setValues([[
status,
Number(nextReplyIndex || 1),
internalSheet
.getRange(rowNumber, YT_CFG.INTERNAL_COL.TOTAL_REPLY_COUNT)
.getValue(),
pageToken || ''
]]);
}
/**
* 완료 처리
*/
function finishCollection_(ss, commentsSheet, state) {
state.status = 'COMPLETED';
state.updatedAt = new Date().toISOString();
state.finishedAt = new Date().toISOString();
state.message = state.topCount < state.targetTopLevelCount
? '댓글 ' +
state.topCount +
'개와 대댓글 ' +
state.replyCount +
'개를 수집했습니다.'
: '댓글 수집을 완료했습니다.';
state.error = null;
finalizeCommentsSheet_(commentsSheet);
saveLastResult_(state, 'COMPLETED', state.message, null);
const props = getUserProperties_();
props.deleteProperty(YT_CFG.STATE_PROP);
props.deleteProperty(YT_CFG.CANCEL_REQUEST_PROP);
deleteContinuationTriggers_();
}
function isCancelRequested_() {
return getUserProperties_().getProperty(YT_CFG.CANCEL_REQUEST_PROP) === 'true';
}
/**
* 취소 처리
*/
function finalizeCancellation_(ss, commentsSheet, state) {
const message = '사용자가 수집을 취소했습니다. 이미 저장된 댓글은 유지됩니다.';
state.status = 'CANCELLED';
state.updatedAt = new Date().toISOString();
state.finishedAt = new Date().toISOString();
state.message = message;
state.error = null;
finalizeCommentsSheet_(commentsSheet);
saveLastResult_(state, 'CANCELLED', message, null);
const props = getUserProperties_();
props.deleteProperty(YT_CFG.STATE_PROP);
props.deleteProperty(YT_CFG.CANCEL_REQUEST_PROP);
deleteContinuationTriggers_();
}
/**
* API 키가 YouTube Data API 요청에 사용 가능한지 확인합니다.
*/
function validateApiKey_(apiKey) {
youtubeGetWithKey_(
'videos',
{
part: 'id',
id: 'jNQXAC9IVRw'
},
apiKey
);
}
/**
* 영상 메타데이터를 조회합니다.
*/
function getVideoMetadataWithKey_(videoId, apiKey) {
const data = youtubeGetWithKey_(
'videos',
{
part: 'snippet,statistics',
id: videoId
},
apiKey
);
if (!Array.isArray(data.items) || data.items.length === 0) {
throwStructuredError_(buildErrorInfo_(
'VIDEO_NOT_FOUND',
'영상 정보를 찾을 수 없습니다.',
'입력한 영상 주소에 해당하는 공개 영상을 YouTube Data API에서 조회하지 못했습니다.',
[
'영상 주소나 11자리 영상 ID가 정확하지 않을 수 있습니다.',
'영상이 삭제되었거나 비공개 상태일 수 있습니다.',
'연령·지역·계정 권한에 따라 접근이 제한된 영상일 수 있습니다.'
],
[
'브라우저에서 해당 영상 주소가 정상적으로 열리는지 확인합니다.',
'주소 표시줄의 영상 URL 전체를 다시 복사합니다.',
'사이드바의 영상 주소를 교체한 뒤 수집을 다시 시작합니다.'
],
{
focusTarget: 'videoUrl',
actionLabel: '영상 주소 다시 입력',
technical: { videoId: videoId }
}
));
}
const item = data.items[0];
const snippet = item.snippet || {};
const statistics = item.statistics || {};
return {
title: snippet.title || '',
channelId: snippet.channelId || '',
channelTitle: snippet.channelTitle || '',
commentCount: statistics.commentCount || ''
};
}
/**
* 저장된 API 키로 YouTube Data API를 호출합니다.
*/
function youtubeGet_(resource, params) {
return youtubeGetWithKey_(resource, params, getApiKey_());
}
/**
* YouTube Data API GET 요청을 수행합니다.
*/
function youtubeGetWithKey_(resource, params, apiKey) {
const queryParams = Object.assign({}, params, { key: apiKey });
const query = Object.keys(queryParams)
.filter(function(key) {
const value = queryParams[key];
return value !== undefined && value !== null && value !== '';
})
.map(function(key) {
return encodeURIComponent(key) + '=' + encodeURIComponent(queryParams[key]);
})
.join('&');
const url = YT_CFG.API_BASE + '/' + resource + '?' + query;
const transientCodes = [429, 500, 502, 503, 504];
for (let attempt = 0; attempt < 3; attempt += 1) {
let response;
try {
response = UrlFetchApp.fetch(url, {
method: 'get',
muteHttpExceptions: true,
headers: { Accept: 'application/json' }
});
} catch (networkError) {
if (attempt < 2) {
Utilities.sleep(Math.pow(2, attempt) * 1000);
continue;
}
throwStructuredError_(buildErrorInfo_(
'NETWORK_REQUEST_FAILED',
'YouTube 서버에 연결하지 못했습니다.',
'Apps Script에서 YouTube Data API로 보내는 외부 요청이 완료되지 않았습니다.',
[
'Google 서비스 또는 네트워크에 일시적인 장애가 발생했을 수 있습니다.',
'Apps Script의 외부 요청 권한이 승인되지 않았을 수 있습니다.'
],
[
'잠시 뒤 같은 작업을 다시 실행합니다.',
'Apps Script 권한 승인 화면이 나타나면 요청된 권한을 허용합니다.',
'Apps Script의 ‘실행’ 메뉴에서 실패 기록을 확인합니다.'
],
{
actionLabel: '확인',
technical: {
resource: resource,
originalMessage: String(networkError && networkError.message || networkError)
}
}
));
}
const statusCode = response.getResponseCode();
const responseText = response.getContentText();
if (statusCode >= 200 && statusCode < 300) {
return responseText ? JSON.parse(responseText) : {};
}
if (transientCodes.indexOf(statusCode) >= 0 && attempt < 2) {
Utilities.sleep(Math.pow(2, attempt) * 1000);
continue;
}
const parsedError = parseYouTubeErrorResponse_(responseText);
throwStructuredError_(classifyYouTubeApiError_(
statusCode,
parsedError.reason,
parsedError.message,
resource
));
}
throwStructuredError_(buildErrorInfo_(
'YOUTUBE_REQUEST_REPEATED_FAILURE',
'YouTube API 요청이 반복적으로 실패했습니다.',
'일시적인 서버 장애가 재시도 후에도 계속되었습니다.',
['YouTube 또는 Google 네트워크 서비스가 불안정할 수 있습니다.'],
[
'잠시 뒤 같은 작업을 다시 실행합니다.',
'반복되면 Apps Script의 ‘실행’ 기록을 확인합니다.'
],
{ actionLabel: '확인', technical: { resource: resource } }
));
}
function parseYouTubeErrorResponse_(responseText) {
let reason = '';
let message = String(responseText || '');
try {
const parsed = JSON.parse(responseText);
if (parsed.error) {
message = parsed.error.message || message;
if (Array.isArray(parsed.error.errors) && parsed.error.errors[0]) {
reason = parsed.error.errors[0].reason || '';
}
if (!reason && parsed.error.status) {
reason = parsed.error.status;
}
}
} catch (parseError) {
// JSON이 아닌 오류 본문은 문자열 그대로 분류합니다.
}
return {
reason: String(reason || ''),
message: String(message || '')
};
}
/**
* YouTube API 오류를 한국어 모달용 정보로 분류합니다.
*/
function classifyYouTubeApiError_(statusCode, reason, message, resource) {
const reasonText = String(reason || '');
const messageText = String(message || '');
const combined = (reasonText + ' ' + messageText).toLowerCase();
const technical = {
httpStatus: statusCode,
apiReason: reasonText,
resource: resource,
originalMessage: messageText
};
if (
combined.indexOf('keyinvalid') >= 0 ||
combined.indexOf('api key not valid') >= 0 ||
combined.indexOf('api_key_invalid') >= 0
) {
return buildErrorInfo_(
'API_KEY_INVALID',
'API 키를 사용할 수 없습니다.',
'입력한 API 키가 정확하지 않거나 삭제·재발급되어 현재 요청에 사용할 수 없습니다.',
[
'API 키 일부가 누락되었을 수 있습니다.',
'Google Cloud에서 기존 키가 삭제되거나 재발급되었을 수 있습니다.',
'API 키가 아닌 다른 프로젝트 값을 입력했을 수 있습니다.'
],
[
'Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.',
'키 유형이 ‘API 키’인지 확인합니다.',
'API 키 전체를 다시 복사합니다.',
'사이드바에서 API 키를 다시 입력하고 저장합니다.'
],
{
helpLabel: 'API 키 관리 화면 열기',
helpUrl: 'https://console.cloud.google.com/apis/credentials',
focusTarget: 'apiKey',
actionLabel: 'API 키 다시 입력',
technical: technical
}
);
}
if (
combined.indexOf('accessnotconfigured') >= 0 ||
combined.indexOf('access not configured') >= 0 ||
combined.indexOf('service_disabled') >= 0 ||
combined.indexOf('api_disabled') >= 0 ||
combined.indexOf('has not been used in project') >= 0 ||
combined.indexOf('it is disabled') >= 0
) {
return buildErrorInfo_(
'YOUTUBE_API_NOT_ENABLED',
'YouTube Data API v3가 활성화되어 있지 않습니다.',
'API 키가 속한 Google Cloud 프로젝트에서 YouTube Data API v3 사용 설정을 켜야 합니다.',
[
'API 키를 만든 프로젝트에서 YouTube Data API v3를 아직 활성화하지 않았습니다.',
'API를 활성화한 프로젝트와 API 키를 발급한 프로젝트가 다를 수 있습니다.'
],
[
'Google Cloud Console에서 API 키를 만든 프로젝트를 선택합니다.',
'‘API 및 서비스 → 라이브러리’로 이동합니다.',
'‘YouTube Data API v3’를 검색하고 ‘사용’을 누릅니다.',
'설정 반영을 위해 1~5분 기다린 뒤 API 키를 다시 저장합니다.'
],
{
helpLabel: 'YouTube Data API 설정 열기',
helpUrl: 'https://console.cloud.google.com/apis/library/youtube.googleapis.com',
focusTarget: 'apiKey',
actionLabel: '설정 확인',
technical: technical
}
);
}
if (
combined.indexOf('iprefererblocked') >= 0 ||
combined.indexOf('referer') >= 0 && combined.indexOf('blocked') >= 0 ||
combined.indexOf('api key restrictions') >= 0
) {
return buildErrorInfo_(
'API_KEY_APPLICATION_RESTRICTION',
'API 키의 애플리케이션 제한이 Apps Script 요청을 차단했습니다.',
'현재 키에 설정된 웹사이트·IP 제한이 Apps Script의 UrlFetch 요청 방식과 맞지 않습니다.',
[
'HTTP 리퍼러 제한 또는 특정 IP 주소 제한이 적용되어 있을 수 있습니다.'
],
[
'Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.',
'사용 중인 API 키의 애플리케이션 제한을 확인합니다.',
'Apps Script에서 사용할 수 있는 제한 방식으로 조정합니다.',
'API 제한은 ‘YouTube Data API v3’로 유지합니다.'
],
{
helpLabel: 'API 키 제한 설정 열기',
helpUrl: 'https://console.cloud.google.com/apis/credentials',
focusTarget: 'apiKey',
actionLabel: '설정 확인',
technical: technical
}
);
}
if (
combined.indexOf('accessnotconfigured') < 0 &&
(
combined.indexOf('forbidden') >= 0 &&
combined.indexOf('api') >= 0 &&
combined.indexOf('key') >= 0
)
) {
return buildErrorInfo_(
'API_KEY_API_RESTRICTION',
'API 키의 API 제한 설정을 확인해야 합니다.',
'현재 API 키가 YouTube Data API v3 호출을 허용하지 않을 수 있습니다.',
[
'API 키의 ‘API 제한’ 목록에서 YouTube Data API v3가 빠져 있을 수 있습니다.'
],
[
'Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.',
'사용 중인 API 키를 선택합니다.',
'‘API 제한’에서 YouTube Data API v3를 허용합니다.',
'저장 후 1~5분 뒤 다시 실행합니다.'
],
{
helpLabel: 'API 키 제한 설정 열기',
helpUrl: 'https://console.cloud.google.com/apis/credentials',
focusTarget: 'apiKey',
actionLabel: '설정 확인',
technical: technical
}
);
}
if (
combined.indexOf('quotaexceeded') >= 0 ||
combined.indexOf('dailylimitexceeded') >= 0 ||
combined.indexOf('daily limit') >= 0
) {
return buildErrorInfo_(
'YOUTUBE_QUOTA_EXCEEDED',
'YouTube API의 오늘 할당량을 모두 사용했습니다.',
'현재 Google Cloud 프로젝트에서 오늘 사용할 수 있는 YouTube Data API 할당량이 남아 있지 않습니다.',
['같은 프로젝트의 다른 프로그램에서도 YouTube API 할당량을 사용했을 수 있습니다.'],
[
'Google Cloud Console에서 YouTube Data API 사용량을 확인합니다.',
'일일 할당량이 갱신된 뒤 다시 실행합니다.',
'필요하면 다음 수집의 댓글 개수를 줄입니다.'
],
{
helpLabel: 'YouTube API 할당량 열기',
helpUrl: 'https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas',
actionLabel: '확인',
technical: technical
}
);
}
if (
combined.indexOf('ratelimitexceeded') >= 0 ||
combined.indexOf('userratelimitexceeded') >= 0 ||
statusCode === 429
) {
return buildErrorInfo_(
'YOUTUBE_RATE_LIMIT',
'짧은 시간에 API 요청이 많이 발생했습니다.',
'YouTube Data API가 일시적으로 요청 속도를 제한했습니다.',
['여러 수집 작업을 연속으로 실행했을 수 있습니다.'],
[
'현재 작업이 자동 재개되는지 확인합니다.',
'몇 분 뒤 다시 실행합니다.',
'동시에 여러 스프레드시트에서 같은 API 키를 사용하고 있다면 실행 횟수를 줄입니다.'
],
{ actionLabel: '확인', technical: technical }
);
}
if (combined.indexOf('commentsdisabled') >= 0) {
return buildErrorInfo_(
'COMMENTS_DISABLED',
'이 영상에서는 댓글을 수집할 수 없습니다.',
'영상 게시자가 댓글 기능을 사용하지 않도록 설정했습니다.',
['영상의 댓글 기능이 꺼져 있거나 YouTube 정책에 따라 제한되었습니다.'],
[
'영상 페이지에서 댓글 영역이 표시되는지 확인합니다.',
'댓글이 허용된 다른 영상 주소를 입력합니다.'
],
{
focusTarget: 'videoUrl',
actionLabel: '영상 주소 다시 입력',
technical: technical
}
);
}
if (
combined.indexOf('videonotfound') >= 0 ||
combined.indexOf('video not found') >= 0
) {
return buildErrorInfo_(
'VIDEO_NOT_FOUND',
'영상 정보를 찾을 수 없습니다.',
'영상 주소가 잘못되었거나 영상이 삭제·비공개 상태일 수 있습니다.',
[
'영상 ID가 정확하지 않을 수 있습니다.',
'영상 공개 상태가 변경되었을 수 있습니다.'
],
[
'브라우저에서 영상 주소가 정상적으로 열리는지 확인합니다.',
'영상 URL 전체를 다시 복사해 입력합니다.'
],
{
focusTarget: 'videoUrl',
actionLabel: '영상 주소 다시 입력',
technical: technical
}
);
}
if (statusCode >= 500) {
return buildErrorInfo_(
'YOUTUBE_SERVER_ERROR',
'YouTube 서버에서 일시적인 오류가 발생했습니다.',
'YouTube Data API 서버가 현재 요청을 정상적으로 처리하지 못했습니다.',
['YouTube 측의 일시적인 서비스 장애일 수 있습니다.'],
[
'잠시 뒤 같은 작업을 다시 실행합니다.',
'진행 중인 수집이라면 자동 재개 여부를 확인합니다.'
],
{ actionLabel: '확인', technical: technical }
);
}
if (statusCode === 403) {
return buildErrorInfo_(
'YOUTUBE_ACCESS_FORBIDDEN',
'YouTube 데이터에 접근할 수 없습니다.',
'API 키 설정, 영상 공개 상태 또는 Google Cloud 프로젝트 권한을 확인해야 합니다.',
[
'API 키 제한 설정이 요청을 차단했을 수 있습니다.',
'영상이 공개 API에서 접근할 수 없는 상태일 수 있습니다.'
],
[
'API 키의 API 제한과 애플리케이션 제한을 확인합니다.',
'영상이 공개 상태인지 확인합니다.',
'Google Cloud에서 YouTube Data API v3가 활성화되어 있는지 확인합니다.'
],
{
helpLabel: 'API 키 설정 열기',
helpUrl: 'https://console.cloud.google.com/apis/credentials',
actionLabel: '설정 확인',
technical: technical
}
);
}
if (statusCode === 400) {
return buildErrorInfo_(
'YOUTUBE_BAD_REQUEST',
'YouTube API 요청 형식을 확인해야 합니다.',
'영상 주소 또는 요청 매개변수 중 하나를 YouTube API가 처리하지 못했습니다.',
['영상 주소가 올바르지 않거나 API 요청 값이 허용 범위를 벗어났을 수 있습니다.'],
[
'영상 주소를 다시 확인합니다.',
'수집 개수와 정렬 기준을 확인합니다.',
'같은 문제가 계속되면 새 수집으로 다시 시작합니다.'
],
{
focusTarget: 'videoUrl',
actionLabel: '입력값 확인',
technical: technical
}
);
}
return buildErrorInfo_(
'YOUTUBE_API_ERROR',
'YouTube API 요청을 완료하지 못했습니다.',
'YouTube Data API가 요청을 거부하거나 처리하지 못했습니다.',
['API 키, 영상 공개 상태, 할당량 또는 네트워크 상태를 확인해야 합니다.'],
[
'API 키 설정과 YouTube Data API 활성화 여부를 확인합니다.',
'영상 주소가 공개 영상인지 확인합니다.',
'잠시 뒤 다시 실행합니다.'
],
{ actionLabel: '확인', technical: technical }
);
}
/**
* 영상 URL 또는 11자리 영상 ID에서 videoId를 추출합니다.
*/
function extractVideoId_(input) {
const text = String(input || '').trim();
if (/^[A-Za-z0-9_-]{11}$/.test(text)) {
return text;
}
const patterns = [
/[?&]v=([A-Za-z0-9_-]{11})/,
/youtu\.be\/([A-Za-z0-9_-]{11})/,
/youtube(?:-nocookie)?\.com\/(?:embed|shorts|live)\/([A-Za-z0-9_-]{11})/
];
for (let i = 0; i < patterns.length; i += 1) {
const match = text.match(patterns[i]);
if (match) {
return match[1];
}
}
throwStructuredError_(buildErrorInfo_(
'INVALID_VIDEO_URL',
'YouTube 영상 주소를 확인해야 합니다.',
'입력한 값에서 11자리 YouTube 영상 ID를 찾지 못했습니다.',
[
'영상 주소 일부가 빠졌을 수 있습니다.',
'채널·재생목록 주소를 입력했을 수 있습니다.'
],
[
'YouTube에서 수집할 영상을 엽니다.',
'브라우저 주소 표시줄의 영상 URL 전체를 복사합니다.',
'사이드바의 영상 주소 입력란에 붙여 넣습니다.'
],
{
focusTarget: 'videoUrl',
actionLabel: '영상 주소 다시 입력'
}
));
}
/**
* 사이드바 입력값을 검증합니다.
*/
function normalizeSidebarInput_(formData) {
const form = formData || {};
const videoUrl = String(form.videoUrl || '').trim();
const limit = parseInt(form.limit, 10);
if (!videoUrl) {
throwStructuredError_(buildErrorInfo_(
'VIDEO_URL_REQUIRED',
'YouTube 영상 주소를 입력해야 합니다.',
'수집 대상 영상 주소가 비어 있습니다.',
['영상 주소 입력란에 값이 없습니다.'],
['수집할 YouTube 영상의 URL을 입력합니다.'],
{
focusTarget: 'videoUrl',
actionLabel: '영상 주소 입력'
}
));
}
if (!Number.isInteger(limit)) {
throwStructuredError_(buildErrorInfo_(
'COMMENT_LIMIT_INVALID',
'수집 개수를 정수로 입력해야 합니다.',
'수집할 최상위 댓글 개수가 올바른 정수 형식이 아닙니다.',
['빈 값, 소수 또는 문자가 입력되었을 수 있습니다.'],
['1 이상의 정수를 입력합니다.'],
{
focusTarget: 'limit',
actionLabel: '수집 개수 입력'
}
));
}
if (limit < YT_CFG.MIN_LIMIT || limit > YT_CFG.MAX_LIMIT) {
throwStructuredError_(buildErrorInfo_(
'COMMENT_LIMIT_OUT_OF_RANGE',
'수집 개수가 허용 범위를 벗어났습니다.',
'수집 개수는 ' + YT_CFG.MIN_LIMIT + '개 이상 ' +
YT_CFG.MAX_LIMIT + '개 이하로 입력해야 합니다.',
['입력한 개수가 코드에 설정된 운영 범위를 벗어났습니다.'],
[
YT_CFG.MIN_LIMIT + '~' + YT_CFG.MAX_LIMIT +
' 사이의 정수를 입력합니다.'
],
{
focusTarget: 'limit',
actionLabel: '수집 개수 수정'
}
));
}
return {
videoUrl: videoUrl,
limit: limit,
includeReplies: form.includeReplies === true
};
}
/**
* 결과 시트와 숨김 내부 시트를 준비합니다.
*/
function ensureInternalSheet_(ss) {
let internal = ss.getSheetByName(YT_CFG.INTERNAL_SHEET);
if (!internal) {
internal = ss.insertSheet(YT_CFG.INTERNAL_SHEET);
}
ensureColumnCount_(internal, YT_CFG.INTERNAL_HEADERS.length);
if (internal.getLastRow() === 0) {
internal
.getRange(1, 1, 1, YT_CFG.INTERNAL_HEADERS.length)
.setValues([YT_CFG.INTERNAL_HEADERS]);
}
internal.setFrozenRows(1);
if (!internal.isSheetHidden()) {
internal.hideSheet();
}
}
/**
* 새 수집에 사용할 결과 시트를 시트1, 시트2, 시트3 ... 순서로 만듭니다.
*
* 새 스프레드시트에 기본 빈 '시트1' 하나만 존재하는 경우에는 그 시트를
* 첫 결과 시트로 사용합니다. 그 이후 수집부터는 항상 새 시트를 추가합니다.
*/
function createNextOutputSheet_(ss) {
const sheets = ss.getSheets();
if (
sheets.length === 1 &&
sheets[0].getName() === YT_CFG.OUTPUT_SHEET_PREFIX + '1' &&
isSheetCompletelyBlank_(sheets[0])
) {
prepareOutputSheet_(sheets[0]);
return sheets[0];
}
let maxNumber = 0;
const namePattern = new RegExp(
'^' + escapeRegExp_(YT_CFG.OUTPUT_SHEET_PREFIX) + '(\\d+)$'
);
sheets.forEach(function(sheet) {
const match = sheet.getName().match(namePattern);
if (match) {
maxNumber = Math.max(maxNumber, Number(match[1] || 0));
}
});
let nextNumber = maxNumber + 1;
let nextName = YT_CFG.OUTPUT_SHEET_PREFIX + nextNumber;
while (ss.getSheetByName(nextName)) {
nextNumber += 1;
nextName = YT_CFG.OUTPUT_SHEET_PREFIX + nextNumber;
}
const sheet = ss.insertSheet(nextName);
prepareOutputSheet_(sheet);
return sheet;
}
function isSheetCompletelyBlank_(sheet) {
return sheet.getLastRow() === 0 && sheet.getLastColumn() === 0;
}
function prepareOutputSheet_(sheet) {
if (sheet.getFilter()) {
sheet.getFilter().remove();
}
sheet.clear();
ensureExactColumnCount_(sheet, YT_CFG.HEADERS.length);
sheet
.getRange(1, 1, 1, YT_CFG.HEADERS.length)
.setValues([YT_CFG.HEADERS]);
sheet.setFrozenRows(1);
sheet.getRange('A:A').setNumberFormat('@');
formatCommentsSheet_(sheet);
}
/**
* 새 수집을 위해 숨김 내부 시트만 초기화합니다.
* 이전 결과 시트의 내용은 변경하지 않습니다.
*/
function resetInternalData_(ss) {
const internal = ss.getSheetByName(YT_CFG.INTERNAL_SHEET);
if (!internal) {
throwStructuredError_(buildErrorInfo_(
'INTERNAL_SHEET_NOT_FOUND',
'내부 관리 시트를 준비하지 못했습니다.',
'댓글 수집 상태를 저장하는 숨김 시트가 존재하지 않습니다.',
['내부 시트 생성 과정이 완료되지 않았습니다.'],
['사이드바를 닫았다가 다시 열고 새 수집을 시작합니다.'],
{ actionLabel: '확인' }
));
}
internal.clear();
ensureExactColumnCount_(internal, YT_CFG.INTERNAL_HEADERS.length);
internal
.getRange(1, 1, 1, YT_CFG.INTERNAL_HEADERS.length)
.setValues([YT_CFG.INTERNAL_HEADERS]);
internal.setFrozenRows(1);
if (!internal.isSheetHidden()) {
internal.hideSheet();
}
}
/**
* 현재 수집 상태에 연결된 결과 시트를 찾습니다.
*/
function getOutputSheetForState_(ss, state) {
const expectedId = Number(state.outputSheetId || 0);
let outputSheet = null;
if (expectedId > 0) {
outputSheet = ss.getSheets().find(function(sheet) {
return sheet.getSheetId() === expectedId;
}) || null;
}
if (!outputSheet && state.outputSheetName) {
outputSheet = ss.getSheetByName(String(state.outputSheetName));
}
if (!outputSheet) {
throwStructuredError_(buildErrorInfo_(
'OUTPUT_SHEET_NOT_FOUND',
'수집 결과 시트를 찾을 수 없습니다.',
'현재 수집 작업과 연결된 결과 시트가 삭제되었거나 이름과 식별 정보가 변경되었습니다.',
[
'수집 중 결과 시트를 삭제했을 수 있습니다.',
'스프레드시트 구조가 외부 작업으로 변경되었을 수 있습니다.'
],
[
'현재 수집을 취소합니다.',
'사이드바에서 같은 영상을 새로 수집합니다.'
],
{ actionLabel: '새 수집 필요' }
));
}
return outputSheet;
}
function escapeRegExp_(text) {
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* 이전 버전이 만든 수집로그 시트를 제거합니다.
*/
function deleteLegacyLogSheet_(ss) {
const logSheet = ss.getSheetByName(YT_CFG.LEGACY_LOG_SHEET);
if (logSheet && ss.getSheets().length > 1) {
ss.deleteSheet(logSheet);
}
}
/**
* 댓글 시트를 번호 순으로 정렬하고 최종 서식을 적용합니다.
*/
function finalizeCommentsSheet_(sheet) {
sortCommentsByDisplayNumber_(sheet);
formatCommentsSheet_(sheet);
}
function sortCommentsByDisplayNumber_(sheet) {
const lastRow = sheet.getLastRow();
if (lastRow <= 2) {
return;
}
// 정렬용 임시 열 I, J를 만든 뒤 최상위 번호와 답글 번호로 정렬합니다.
ensureColumnCount_(sheet, 10);
const numberValues = sheet
.getRange(2, 1, lastRow - 1, 1)
.getDisplayValues();
const sortKeys = numberValues.map(function(row) {
const parts = String(row[0] || '').split('_');
return [
Number(parts[0] || 0),
parts.length > 1 ? Number(parts[1] || 0) : 0
];
});
sheet
.getRange(2, 9, sortKeys.length, 2)
.setValues(sortKeys);
sheet
.getRange(2, 1, lastRow - 1, 10)
.sort([
{ column: 9, ascending: true },
{ column: 10, ascending: true }
]);
sheet.getRange(2, 9, lastRow - 1, 2).clearContent();
ensureExactColumnCount_(sheet, YT_CFG.HEADERS.length);
}
function formatCommentsSheet_(sheet) {
const lastRow = sheet.getLastRow();
sheet.setFrozenRows(1);
sheet
.getRange(1, 1, 1, YT_CFG.HEADERS.length)
.setFontWeight('bold')
.setWrap(true)
.setVerticalAlignment('middle');
sheet.getRange('A:A').setNumberFormat('@');
if (lastRow > 1) {
sheet
.getRange(2, 7, lastRow - 1, 1)
.setNumberFormat('yyyy-mm-dd hh:mm:ss');
sheet
.getRange(2, 5, lastRow - 1, 1)
.setWrap(true)
.setVerticalAlignment('top');
sheet
.getRange(2, 1, lastRow - 1, YT_CFG.HEADERS.length)
.setVerticalAlignment('top');
if (sheet.getFilter()) {
sheet.getFilter().remove();
}
sheet
.getRange(1, 1, lastRow, YT_CFG.HEADERS.length)
.createFilter();
}
sheet.setColumnWidth(1, 90);
sheet.setColumnWidth(2, 180);
sheet.setColumnWidth(3, 260);
sheet.setColumnWidth(4, 170);
sheet.setColumnWidth(5, 520);
sheet.setColumnWidth(6, 95);
sheet.setColumnWidth(7, 155);
sheet.setColumnWidth(8, 95);
}
/**
* 숨김 내부 시트에 저장된 댓글 ID 집합을 만듭니다.
*/
function getExistingCommentIds_(internalSheet) {
const lastRow = internalSheet.getLastRow();
if (lastRow <= 1) {
return new Set();
}
const values = internalSheet
.getRange(2, YT_CFG.INTERNAL_COL.COMMENT_ID, lastRow - 1, 1)
.getDisplayValues();
return new Set(
values
.map(function(row) {
return row[0];
})
.filter(function(value) {
return Boolean(value);
})
);
}
/**
* 숨김 내부 시트 기준으로 실제 최상위 댓글·답글 수를 계산합니다.
*/
function getExistingCounts_(internalSheet) {
const lastRow = internalSheet.getLastRow();
const counts = { top: 0, reply: 0 };
if (lastRow <= 1) {
return counts;
}
const values = internalSheet
.getRange(2, YT_CFG.INTERNAL_COL.COMMENT_TYPE, lastRow - 1, 1)
.getDisplayValues();
values.forEach(function(row) {
if (row[0] === 'topLevel') {
counts.top += 1;
} else if (row[0] === 'reply') {
counts.reply += 1;
}
});
return counts;
}
/**
* 댓글 글자수를 지정된 바이트 규칙으로 계산합니다.
* - ASCII 영역(영문, 숫자, 공백, 일반 문장부호): 1바이트
* - 한글을 포함한 비ASCII 유니코드 문자: 코드 포인트당 2바이트
*/
function calculateCommentBytes_(value) {
const text = String(value || '');
let bytes = 0;
Array.from(text).forEach(function(character) {
const codePoint = character.codePointAt(0);
bytes += codePoint <= 0x7F ? 1 : 2;
});
return bytes;
}
/**
* 외부 문자열이 시트 수식으로 실행되는 것을 방지합니다.
*/
function safeCellText_(value) {
const text = value === undefined || value === null ? '' : String(value);
return /^[=+\-@]/.test(text) ? "'" + text : text;
}
function apiDateToSheetValue_(value) {
return value ? new Date(value) : '';
}
function ensureColumnCount_(sheet, requiredColumns) {
const current = sheet.getMaxColumns();
if (current < requiredColumns) {
sheet.insertColumnsAfter(current, requiredColumns - current);
}
}
function ensureExactColumnCount_(sheet, requiredColumns) {
ensureColumnCount_(sheet, requiredColumns);
const current = sheet.getMaxColumns();
if (current > requiredColumns) {
sheet.deleteColumns(requiredColumns + 1, current - requiredColumns);
}
}
function getUserProperties_() {
return PropertiesService.getUserProperties();
}
function getApiKey_() {
const apiKey = getUserProperties_().getProperty(YT_CFG.API_KEY_PROP);
if (!apiKey) {
throwStructuredError_(buildErrorInfo_(
'API_KEY_REQUIRED',
'API 키 설정이 필요합니다.',
'댓글 수집을 시작하기 전에 YouTube Data API 키를 저장해야 합니다.',
['현재 사용자 계정에 저장된 API 키가 없습니다.'],
[
'사이드바 상단의 API 키 입력란에 키를 입력합니다.',
'‘API 키 확인 후 저장’을 누릅니다.'
],
{
focusTarget: 'apiKey',
actionLabel: 'API 키 입력'
}
));
}
return apiKey.trim();
}
function loadState_() {
const text = getUserProperties_().getProperty(YT_CFG.STATE_PROP);
return text ? JSON.parse(text) : null;
}
function saveState_(state) {
state.updatedAt = new Date().toISOString();
const text = JSON.stringify(state);
const byteLength = Utilities.newBlob(text).getBytes().length;
if (byteLength > 8500) {
throwStructuredError_(buildErrorInfo_(
'STATE_STORAGE_LIMIT',
'수집 진행 상태를 저장할 수 없습니다.',
'Apps Script 사용자 속성의 안전 저장 크기를 초과했습니다.',
['진행 상태 데이터가 예상보다 커졌습니다.'],
[
'현재 수집을 취소합니다.',
'댓글 수집 개수를 줄여 새로 실행합니다.'
],
{ actionLabel: '확인', technical: { byteLength: byteLength } }
));
}
getUserProperties_().setProperty(YT_CFG.STATE_PROP, text);
}
function loadLastResult_() {
const text = getUserProperties_().getProperty(YT_CFG.LAST_RESULT_PROP);
return text ? JSON.parse(text) : null;
}
function saveLastResult_(state, status, message, errorInfo) {
const result = {
status: status,
outputSheetId: Number(state.outputSheetId || 0),
outputSheetName: state.outputSheetName || '',
videoId: state.videoId || '',
videoTitle: state.videoTitle || '',
targetTopLevelCount: Number(state.targetTopLevelCount || 0),
topCount: Number(state.topCount || 0),
replyCount: Number(state.replyCount || 0),
apiCalls: Number(state.apiCalls || 0),
startedAt: state.startedAt || '',
updatedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
message: message || '',
error: errorInfo || null
};
getUserProperties_().setProperty(
YT_CFG.LAST_RESULT_PROP,
JSON.stringify(result)
);
}
function validateStateVersion_(state) {
if (Number(state.version || 0) !== YT_CFG.STATE_VERSION) {
throwStructuredError_(buildErrorInfo_(
'STATE_VERSION_MISMATCH',
'이전 코드에서 시작된 수집 작업을 이어갈 수 없습니다.',
'현재 Code.gs의 댓글 저장 구조가 이전 실행 상태와 다릅니다.',
['고정 결과 시트 또는 정렬 기준을 사용하던 이전 코드의 작업 상태가 남아 있습니다.'],
[
'사이드바에서 진행 중인 수집을 취소합니다.',
'현재 코드로 같은 영상을 새로 수집합니다.'
],
{ actionLabel: '새 수집 필요' }
));
}
}
function normalizeState_(state) {
state.currentReplyParentId = state.currentReplyParentId || '';
state.nextReplyPageToken = state.nextReplyPageToken || '';
state.nextThreadPageToken = state.nextThreadPageToken || '';
state.threadDone = Boolean(state.threadDone);
state.topCount = Number(state.topCount || 0);
state.replyCount = Number(state.replyCount || 0);
state.apiCalls = Number(state.apiCalls || 0);
state.threadPages = Number(state.threadPages || 0);
state.replyPages = Number(state.replyPages || 0);
state.targetTopLevelCount = Number(state.targetTopLevelCount || 0);
state.outputSheetId = Number(state.outputSheetId || 0);
state.outputSheetName = String(state.outputSheetName || '');
state.error = state.error || null;
}
function maskApiKey_(apiKey) {
const key = String(apiKey || '');
return '••••••••••••' + key.slice(-4);
}
function buildProgressForClient_(state, status) {
const target = Number(state.targetTopLevelCount || 0);
const top = Number(state.topCount || 0);
const reply = Number(state.replyCount || 0);
const percent = target > 0
? Math.min(100, Math.round((top / target) * 100))
: 0;
let stage = '대기 중';
if (status === 'RUNNING') {
if (state.currentReplyParentId) {
stage = '답글 수집 중';
} else if (!state.threadDone) {
stage = '최상위 댓글 수집 중';
} else {
stage = '마무리 중';
}
} else if (status === 'COMPLETED') {
stage = '완료';
} else if (status === 'ERROR') {
stage = '오류';
} else if (status === 'CANCELLED') {
stage = '취소됨';
}
return {
status: status,
stage: stage,
outputSheetId: Number(state.outputSheetId || 0),
outputSheetName: state.outputSheetName || '',
videoId: state.videoId || '',
videoTitle: state.videoTitle || '',
targetTopLevelCount: target,
topCount: top,
replyCount: reply,
totalRows: top + reply,
apiCalls: Number(state.apiCalls || 0),
percent: percent,
startedAt: state.startedAt || '',
updatedAt: state.updatedAt || '',
message: state.message || '',
error: state.error || null
};
}
function buildServerProgressMessage_(state) {
return (
'최상위 댓글 ' +
state.topCount +
'/' +
state.targetTopLevelCount +
'개, 답글 ' +
state.replyCount +
'개를 저장했습니다.'
);
}
function scheduleContinuation_() {
deleteContinuationTriggers_();
ScriptApp
.newTrigger(YT_CFG.CONTINUE_HANDLER)
.timeBased()
.after(YT_CFG.CONTINUE_AFTER_MS)
.create();
}
function deleteContinuationTriggers_() {
ScriptApp.getProjectTriggers().forEach(function(trigger) {
if (trigger.getHandlerFunction() === YT_CFG.CONTINUE_HANDLER) {
ScriptApp.deleteTrigger(trigger);
}
});
}
/**
* 한국어 모달용 오류 정보를 만듭니다.
*/
function buildErrorInfo_(code, title, summary, causes, steps, options) {
const opts = options || {};
return {
code: String(code || 'UNKNOWN_ERROR'),
title: String(title || '작업을 완료하지 못했습니다.'),
summary: String(summary || '처리 중 문제가 발생했습니다.'),
causes: Array.isArray(causes) ? causes.map(String) : [],
steps: Array.isArray(steps) ? steps.map(String) : [],
helpLabel: String(opts.helpLabel || ''),
helpUrl: String(opts.helpUrl || ''),
focusTarget: String(opts.focusTarget || ''),
actionLabel: String(opts.actionLabel || '확인'),
technical:
opts.technical && typeof opts.technical === 'object'
? opts.technical
: {}
};
}
/**
* 서버 오류를 Sidebar.html이 해석할 수 있는 구조화된 예외로 전달합니다.
*/
function throwStructuredError_(errorInfo) {
throw new Error(
YT_CFG.STRUCTURED_ERROR_PREFIX + JSON.stringify(errorInfo)
);
}
function extractStructuredError_(rawText) {
const raw = String(rawText || '');
const markerIndex = raw.indexOf(YT_CFG.STRUCTURED_ERROR_PREFIX);
if (markerIndex < 0) {
return null;
}
const jsonText = raw.slice(
markerIndex + YT_CFG.STRUCTURED_ERROR_PREFIX.length
);
try {
return JSON.parse(jsonText);
} catch (parseError) {
return null;
}
}
/**
* Apps Script 자체 오류도 한국어 모달 정보로 변환합니다.
*/
function normalizeErrorInfo_(error, context) {
const raw = error && error.message
? String(error.message)
: String(error || '');
const structured = extractStructuredError_(raw);
if (structured) {
return structured;
}
const lower = raw.toLowerCase();
const technical = {
context: String(context || '작업 실행'),
originalMessage: raw
};
if (
lower.indexOf('authorization is required') >= 0 ||
lower.indexOf('you do not have permission') >= 0 ||
lower.indexOf('permissions') >= 0 && lower.indexOf('required') >= 0
) {
return buildErrorInfo_(
'APPS_SCRIPT_AUTH_REQUIRED',
'Google 권한 승인이 필요합니다.',
'현재 계정이 스프레드시트 또는 외부 API 요청 권한을 아직 승인하지 않았습니다.',
['Apps Script를 처음 실행했거나 새로운 권한이 추가되었습니다.'],
[
'스프레드시트 메뉴에서 사이드바를 다시 엽니다.',
'권한 검토 화면에서 현재 Google 계정을 선택합니다.',
'요청되는 권한을 허용한 뒤 다시 실행합니다.'
],
{ actionLabel: '확인', technical: technical }
);
}
if (
lower.indexOf('maximum execution time') >= 0 ||
lower.indexOf('exceeded maximum execution time') >= 0 ||
lower.indexOf('timed out') >= 0
) {
return buildErrorInfo_(
'APPS_SCRIPT_EXECUTION_TIMEOUT',
'한 번의 실행 가능 시간을 초과했습니다.',
'댓글 또는 답글 처리 시간이 길어 현재 실행 구간이 종료되었습니다.',
['대량 댓글 수집으로 한 번의 Apps Script 실행 시간이 길어졌습니다.'],
[
'사이드바를 다시 열어 진행 상태를 확인합니다.',
'멈춘 경우 ‘중단 지점부터 실행’을 한 번 실행합니다.',
'같은 문제가 반복되면 수집 개수를 줄입니다.'
],
{ actionLabel: '확인', technical: technical }
);
}
if (
lower.indexOf('service invoked too many times') >= 0 ||
lower.indexOf('quota') >= 0 && lower.indexOf('apps script') >= 0
) {
return buildErrorInfo_(
'APPS_SCRIPT_SERVICE_QUOTA',
'Apps Script 서비스 사용 한도를 초과했습니다.',
'현재 Google 계정 또는 스크립트의 일일 서비스 사용량이 허용 범위를 넘었습니다.',
['짧은 시간에 많은 외부 요청이나 스프레드시트 쓰기 작업이 실행되었습니다.'],
[
'잠시 뒤 다시 실행합니다.',
'같은 날 반복되면 다음 날 다시 실행합니다.',
'한 번에 수집하는 댓글 개수를 줄입니다.'
],
{ actionLabel: '확인', technical: technical }
);
}
return buildErrorInfo_(
'UNEXPECTED_ERROR',
'예상하지 못한 오류가 발생했습니다.',
'오류 유형을 자동으로 판별하지 못했습니다.',
[
'네트워크, 권한, Google 서비스 상태 또는 실행 환경에서 문제가 발생했을 수 있습니다.'
],
[
'사이드바를 닫았다가 다시 열고 같은 작업을 한 번 더 실행합니다.',
'Apps Script 편집기의 ‘실행’ 메뉴에서 실패 기록을 확인합니다.',
'API 키와 영상 주소를 다시 확인합니다.'
],
{ actionLabel: '확인', technical: technical }
);
}
Sidebar.html
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<meta charset="utf-8">
<style>
:root {
--primary: #1a73e8;
--primary-hover: #1557b0;
--surface: #ffffff;
--surface-soft: #f8fafd;
--border: #dadce0;
--text: #202124;
--muted: #5f6368;
--success: #137333;
--success-bg: #e6f4ea;
--warning: #b06000;
--warning-bg: #fef7e0;
--danger: #b3261e;
--danger-bg: #fce8e6;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 16px;
font-family: Arial, 'Noto Sans KR', sans-serif;
color: var(--text);
background: var(--surface);
font-size: 13px;
line-height: 1.5;
}
h1 {
margin: 0 0 4px;
font-size: 20px;
line-height: 1.3;
}
h2 {
margin: 0 0 12px;
font-size: 15px;
}
p {
margin: 0;
}
.subtitle {
color: var(--muted);
margin-bottom: 16px;
}
.card {
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
margin-bottom: 12px;
background: var(--surface);
}
.card.soft {
background: var(--surface-soft);
}
.row {
display: flex;
align-items: center;
gap: 8px;
}
.row.between {
justify-content: space-between;
}
.field {
margin-bottom: 14px;
}
.field:last-child {
margin-bottom: 0;
}
label {
display: block;
margin-bottom: 6px;
font-weight: 700;
}
input,
button {
font: inherit;
}
input[type='text'],
input[type='password'],
input[type='number'] {
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
color: var(--text);
outline: none;
}
input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.15);
}
input:disabled {
background: #f1f3f4;
color: #80868b;
}
fieldset {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
fieldset:disabled {
opacity: 0.52;
}
.help {
margin-top: 5px;
color: var(--muted);
font-size: 11px;
}
.check-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.check-row input {
margin-top: 3px;
}
.check-row label {
margin: 0;
font-weight: 600;
}
button {
border: 1px solid transparent;
border-radius: 8px;
min-height: 36px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease;
}
button:disabled {
cursor: default;
opacity: 0.55;
}
.primary {
width: 100%;
color: #fff;
background: var(--primary);
font-weight: 700;
}
.primary:hover:not(:disabled) {
background: var(--primary-hover);
}
.secondary {
color: var(--primary);
background: #fff;
border-color: var(--border);
}
.danger {
color: var(--danger);
background: #fff;
border-color: var(--border);
}
.small-button {
min-height: 30px;
padding: 5px 8px;
font-size: 12px;
}
.status-line {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.badge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
font-weight: 700;
}
.badge.success {
color: var(--success);
background: var(--success-bg);
}
.badge.warning {
color: var(--warning);
background: var(--warning-bg);
}
.badge.danger {
color: var(--danger);
background: var(--danger-bg);
}
.masked-key {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
font-family: monospace;
}
.locked-message {
padding: 10px;
margin-bottom: 12px;
border-radius: 8px;
color: var(--warning);
background: var(--warning-bg);
}
.progress-bar {
width: 100%;
height: 8px;
margin: 10px 0 12px;
border-radius: 999px;
overflow: hidden;
background: #e8eaed;
}
.progress-fill {
width: 0;
height: 100%;
background: var(--primary);
transition: width 0.25s ease;
}
.progress-title {
font-weight: 700;
overflow-wrap: anywhere;
}
.metric-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 10px 0;
}
.metric {
padding: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
}
.metric-label {
color: var(--muted);
font-size: 11px;
}
.metric-value {
margin-top: 2px;
font-size: 16px;
font-weight: 700;
}
.message {
display: none;
margin-bottom: 12px;
padding: 10px;
border-radius: 8px;
overflow-wrap: anywhere;
}
.message.show {
display: block;
}
.message.info {
color: #174ea6;
background: #e8f0fe;
}
.message.success {
color: var(--success);
background: var(--success-bg);
}
.message.error {
color: var(--danger);
background: var(--danger-bg);
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
background: rgba(32, 33, 36, 0.48);
}
.modal-panel {
width: 100%;
max-width: 430px;
max-height: calc(100vh - 24px);
overflow-y: auto;
border-radius: 14px;
background: #fff;
box-shadow:
0 12px 28px rgba(60, 64, 67, 0.28),
0 2px 8px rgba(60, 64, 67, 0.18);
}
.modal-header {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border);
}
.modal-icon {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 50%;
color: #fff;
background: var(--danger);
font-size: 18px;
font-weight: 700;
}
.modal-heading {
min-width: 0;
flex: 1 1 auto;
}
.modal-title {
margin: 0;
font-size: 17px;
line-height: 1.35;
overflow-wrap: anywhere;
}
.modal-code {
margin-top: 3px;
color: var(--muted);
font-size: 10px;
font-family: monospace;
overflow-wrap: anywhere;
}
.modal-close {
flex: 0 0 auto;
width: 30px;
min-height: 30px;
padding: 0;
border: 0;
border-radius: 50%;
color: var(--muted);
background: transparent;
font-size: 20px;
line-height: 1;
}
.modal-close:hover {
background: #f1f3f4;
}
.modal-body {
padding: 14px 16px 4px;
}
.modal-summary {
padding: 11px 12px;
border-left: 4px solid var(--danger);
border-radius: 6px;
color: #7a1a14;
background: var(--danger-bg);
font-weight: 700;
overflow-wrap: anywhere;
}
.modal-section {
margin-top: 14px;
}
.modal-section h3 {
margin: 0 0 7px;
font-size: 13px;
}
.modal-section ul,
.modal-section ol {
margin: 0;
padding-left: 20px;
}
.modal-section li {
margin-bottom: 6px;
overflow-wrap: anywhere;
}
.modal-section li:last-child {
margin-bottom: 0;
}
.technical-details {
margin-top: 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-soft);
}
.technical-details summary {
padding: 9px 10px;
cursor: pointer;
color: var(--muted);
font-weight: 700;
}
.technical-grid {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 6px 10px;
padding: 0 10px 10px;
font-size: 11px;
}
.technical-label {
color: var(--muted);
}
.technical-value {
font-family: monospace;
overflow-wrap: anywhere;
}
.modal-actions {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px 16px;
}
.modal-actions .primary,
.modal-actions .secondary {
width: 100%;
}
.modal-help-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
color: var(--primary);
background: #fff;
font-weight: 700;
text-decoration: none;
}
.modal-help-link:hover {
background: var(--surface-soft);
}
.hidden {
display: none !important;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.45);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<h1>YouTube 댓글 수집기</h1>
<p class="subtitle">YouTube Data API v3 → Google Sheets</p>
<div id="message" class="message"></div>
<section class="card">
<h2>1. API 키</h2>
<div id="apiMissingView">
<div class="field">
<label for="apiKey">YouTube Data API 키</label>
<input
id="apiKey"
type="password"
autocomplete="off"
placeholder="AIza..."
>
<div class="help">
최초 1회 저장합니다. 키는 사용자별 Apps Script 속성에 보관됩니다.
</div>
</div>
<button id="saveApiButton" class="primary" type="button">
API 키 확인 후 저장
</button>
</div>
<div id="apiSavedView" class="hidden">
<div class="row between">
<div class="status-line">
<span class="badge success">설정 완료</span>
<span id="apiKeyMask" class="masked-key"></span>
</div>
</div>
<div class="row" style="margin-top: 10px;">
<button id="changeApiButton" class="secondary small-button" type="button">
키 변경
</button>
<button id="deleteApiButton" class="danger small-button" type="button">
키 삭제
</button>
</div>
</div>
</section>
<section class="card soft">
<h2>2. 수집 설정</h2>
<div id="lockedMessage" class="locked-message">
API 키를 저장하면 수집 설정이 활성화됩니다.
</div>
<fieldset id="collectorFieldset" disabled>
<div class="field">
<label for="videoUrl">YouTube 영상 주소</label>
<input
id="videoUrl"
type="text"
placeholder="https://www.youtube.com/watch?v=..."
>
</div>
<div class="field">
<label for="limit">수집할 최상위 댓글 수</label>
<input
id="limit"
type="number"
min="1"
max="50000"
step="1"
value="100"
>
<div class="help">
1~50,000개. 답글 포함 시 실제 저장 행 수가 이 값보다 많아질 수 있습니다.
</div>
</div>
<div class="field check-row">
<input id="includeReplies" type="checkbox" checked>
<label for="includeReplies">
대댓글도 모두 수집
</label>
</div>
<div class="help" style="margin-bottom: 10px;">
새 수집을 시작할 때마다 ‘시트1’, ‘시트2’, ‘시트3’ 형식의 새 시트가 생성됩니다. 기존 수집 결과는 유지됩니다.
</div>
<button id="startButton" class="primary" type="button">
수집 시작
</button>
</fieldset>
</section>
<section id="progressCard" class="card hidden">
<div class="row between">
<h2 style="margin: 0;">진행 상태</h2>
<span id="progressBadge" class="badge warning">대기 중</span>
</div>
<div id="progressTitle" class="progress-title" style="margin-top: 10px;"></div>
<div class="progress-bar">
<div id="progressFill" class="progress-fill"></div>
</div>
<div class="metric-grid">
<div class="metric">
<div class="metric-label">댓글</div>
<div id="topMetric" class="metric-value">0 / 0</div>
</div>
<div class="metric">
<div class="metric-label">대댓글</div>
<div id="replyMetric" class="metric-value">0</div>
</div>
<div class="metric">
<div class="metric-label">전체 저장 행</div>
<div id="totalMetric" class="metric-value">0</div>
</div>
<div class="metric">
<div class="metric-label">API 호출 횟수</div>
<div id="apiCallMetric" class="metric-value">0</div>
</div>
</div>
<p id="progressMessage" class="help"></p>
<button id="cancelButton" class="danger hidden" type="button" style="width: 100%; margin-top: 12px;">
진행 중인 수집 취소
</button>
</section>
<div
id="errorModalBackdrop"
class="modal-backdrop hidden"
role="dialog"
aria-modal="true"
aria-labelledby="errorModalTitle"
>
<div class="modal-panel">
<div class="modal-header">
<div class="modal-icon" aria-hidden="true">!</div>
<div class="modal-heading">
<h2 id="errorModalTitle" class="modal-title">
문제를 확인해야 합니다.
</h2>
<div id="errorModalCode" class="modal-code"></div>
</div>
<button
id="errorModalCloseButton"
class="modal-close"
type="button"
aria-label="오류 안내 닫기"
>
×
</button>
</div>
<div class="modal-body">
<div id="errorModalSummary" class="modal-summary"></div>
<section id="errorCauseSection" class="modal-section">
<h3>발생 가능한 원인</h3>
<ul id="errorCauseList"></ul>
</section>
<section id="errorStepSection" class="modal-section">
<h3>해결 방법</h3>
<ol id="errorStepList"></ol>
</section>
<details id="errorTechnicalDetails" class="technical-details">
<summary>기술 정보 보기</summary>
<div id="errorTechnicalGrid" class="technical-grid"></div>
</details>
</div>
<div class="modal-actions">
<a
id="errorHelpLink"
class="modal-help-link hidden"
href="#"
target="_blank"
rel="noopener noreferrer"
></a>
<button
id="errorModalPrimaryButton"
class="primary"
type="button"
>
확인
</button>
<button
id="errorModalDismissButton"
class="secondary"
type="button"
>
닫기
</button>
</div>
</div>
</div>
<script>
const ERROR_PREFIX = 'YT_STRUCTURED_ERROR::';
let currentState = null;
let pollTimer = null;
let pollInFlight = false;
let lastShownErrorKey = '';
let pendingFocusTarget = '';
document.addEventListener('DOMContentLoaded', function() {
bindEvents();
refreshState(false);
});
function bindEvents() {
document.getElementById('saveApiButton').addEventListener('click', saveApiKey);
document.getElementById('changeApiButton').addEventListener('click', showApiKeyEditor);
document.getElementById('deleteApiButton').addEventListener('click', deleteApiKey);
document.getElementById('startButton').addEventListener('click', startCollection);
document.getElementById('cancelButton').addEventListener('click', cancelCollection);
document
.getElementById('errorModalCloseButton')
.addEventListener('click', closeErrorModal);
document
.getElementById('errorModalDismissButton')
.addEventListener('click', closeErrorModal);
document
.getElementById('errorModalPrimaryButton')
.addEventListener('click', handleErrorModalPrimaryAction);
document
.getElementById('errorModalBackdrop')
.addEventListener('click', function(event) {
if (event.target === event.currentTarget) {
closeErrorModal();
}
});
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeErrorModal();
}
});
}
function refreshState(silent) {
if (pollInFlight) {
return;
}
pollInFlight = true;
google.script.run
.withSuccessHandler(function(state) {
pollInFlight = false;
renderState(state);
})
.withFailureHandler(function(error) {
pollInFlight = false;
stopPolling();
if (!silent) {
handleServerError(error, '상태 확인');
}
})
.getSidebarState();
}
function renderState(state) {
currentState = state || {};
const configured = Boolean(currentState.apiKeyConfigured);
const running = Boolean(currentState.running);
document.getElementById('apiMissingView').classList.toggle('hidden', configured);
document.getElementById('apiSavedView').classList.toggle('hidden', !configured);
document.getElementById('apiKeyMask').textContent = currentState.apiKeyMask || '';
document.getElementById('collectorFieldset').disabled = !configured || running;
document.getElementById('lockedMessage').classList.toggle('hidden', configured);
document.getElementById('changeApiButton').disabled = running;
document.getElementById('deleteApiButton').disabled = running;
renderProgress(currentState.progress, running);
if (running) {
startPolling();
} else {
stopPolling();
}
}
function renderProgress(progress, running) {
const card = document.getElementById('progressCard');
if (!progress) {
card.classList.add('hidden');
return;
}
card.classList.remove('hidden');
const status = progress.status || '';
const badge = document.getElementById('progressBadge');
badge.textContent = progress.stage || status;
badge.className = 'badge ' + getBadgeClass(status);
const progressTitle =
progress.videoTitle || progress.videoId || 'YouTube 영상';
const outputSheetName = progress.outputSheetName || '';
document.getElementById('progressTitle').textContent =
outputSheetName
? progressTitle + ' · ' + outputSheetName
: progressTitle;
document.getElementById('progressFill').style.width =
String(progress.percent || 0) + '%';
document.getElementById('topMetric').textContent =
formatNumber(progress.topCount) + ' / ' + formatNumber(progress.targetTopLevelCount);
document.getElementById('replyMetric').textContent = formatNumber(progress.replyCount);
document.getElementById('totalMetric').textContent = formatNumber(progress.totalRows);
document.getElementById('apiCallMetric').textContent = formatNumber(progress.apiCalls);
document.getElementById('progressMessage').textContent = progress.message || '';
document.getElementById('cancelButton').classList.toggle('hidden', !running);
if (status === 'ERROR') {
const errorInfo = progress.error || buildFallbackErrorInfo(
progress.message || '',
'댓글 수집'
);
const errorKey = [
errorInfo.code || 'UNKNOWN_ERROR',
progress.updatedAt || '',
progress.videoId || ''
].join('|');
if (errorKey !== lastShownErrorKey) {
lastShownErrorKey = errorKey;
showErrorModal(errorInfo);
}
}
}
function saveApiKey() {
const input = document.getElementById('apiKey');
const button = document.getElementById('saveApiButton');
const apiKey = input.value.trim();
if (!apiKey) {
showErrorModal({
code: 'API_KEY_REQUIRED',
title: 'API 키를 입력해야 합니다.',
summary: 'API 키 입력란이 비어 있어 검증을 시작할 수 없습니다.',
causes: [
'입력란에 값이 입력되지 않았습니다.'
],
steps: [
'Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’에서 API 키를 확인합니다.',
'키 값을 복사하여 입력란에 붙여 넣습니다.',
'‘API 키 확인 후 저장’을 누릅니다.'
],
helpLabel: 'API 키 관리 화면 열기',
helpUrl: 'https://console.cloud.google.com/apis/credentials',
focusTarget: 'apiKey',
actionLabel: 'API 키 입력'
});
return;
}
setButtonBusy(button, true, '확인 중');
clearMessage();
google.script.run
.withSuccessHandler(function(state) {
input.value = '';
setButtonBusy(button, false, 'API 키 확인 후 저장');
renderState(state);
showMessage('API 키를 검증하여 저장했습니다.', 'success');
})
.withFailureHandler(function(error) {
setButtonBusy(button, false, 'API 키 확인 후 저장');
handleServerError(error, 'API 키 검증');
})
.saveApiKey(apiKey);
}
function showApiKeyEditor() {
document.getElementById('apiSavedView').classList.add('hidden');
document.getElementById('apiMissingView').classList.remove('hidden');
document.getElementById('apiKey').focus();
showMessage('새 API 키를 입력하면 기존 키가 교체됩니다.', 'info');
}
function deleteApiKey() {
if (!window.confirm('저장된 API 키를 삭제하시겠습니까?')) {
return;
}
clearMessage();
google.script.run
.withSuccessHandler(function(state) {
renderState(state);
showMessage('저장된 API 키를 삭제했습니다.', 'success');
})
.withFailureHandler(function(error) {
handleServerError(error, 'API 키 삭제');
})
.deleteApiKey();
}
function startCollection() {
const videoUrl = document.getElementById('videoUrl').value.trim();
const limitText = document.getElementById('limit').value;
const limit = Number(limitText);
const startButton = document.getElementById('startButton');
if (!videoUrl) {
showErrorModal({
code: 'VIDEO_URL_REQUIRED',
title: '영상 주소를 입력해야 합니다.',
summary: '수집 대상 YouTube 영상 주소가 비어 있습니다.',
causes: [
'영상 주소 입력란에 값이 없습니다.'
],
steps: [
'YouTube에서 수집할 영상을 엽니다.',
'주소창의 전체 영상 주소를 복사하여 입력합니다.'
],
focusTarget: 'videoUrl',
actionLabel: '영상 주소 입력'
});
return;
}
if (!Number.isInteger(limit) || limit < 1 || limit > 50000) {
showErrorModal({
code: 'COMMENT_LIMIT_INVALID',
title: '수집 개수를 확인해야 합니다.',
summary: '수집 개수는 1~50,000 사이의 정수로 입력해야 합니다.',
causes: [
'빈 값, 소수, 문자 또는 허용 범위를 벗어난 수가 입력되었습니다.'
],
steps: [
'수집 개수 입력란에 1~50,000 사이의 정수를 입력합니다.',
'예: 100, 500, 1000'
],
focusTarget: 'limit',
actionLabel: '수집 개수 수정'
});
return;
}
const payload = {
videoUrl: videoUrl,
limit: limit,
includeReplies: document.getElementById('includeReplies').checked
};
setButtonBusy(startButton, true, '준비 중');
clearMessage();
google.script.run
.withSuccessHandler(function(state) {
setButtonBusy(startButton, false, '수집 시작');
renderState(state);
const sheetName =
state &&
state.progress &&
state.progress.outputSheetName
? state.progress.outputSheetName
: '';
showMessage(
sheetName
? sheetName + '에서 수집 작업을 시작했습니다.'
: '수집 작업을 시작했습니다.',
'info'
);
startPolling();
// 서버에서 첫 수집 구간을 즉시 실행합니다.
google.script.run
.withSuccessHandler(function(updatedState) {
renderState(updatedState);
})
.withFailureHandler(function(error) {
refreshState(true);
handleServerError(error, '댓글 수집');
})
.runCollectionNow();
})
.withFailureHandler(function(error) {
setButtonBusy(startButton, false, '수집 시작');
handleServerError(error, '수집 준비');
})
.startCollectionFromSidebar(payload);
}
function cancelCollection() {
if (!window.confirm('현재 수집 작업을 취소하시겠습니까? 이미 저장된 행은 유지됩니다.')) {
return;
}
clearMessage();
google.script.run
.withSuccessHandler(function(state) {
renderState(state);
if (state && state.running) {
showMessage('취소 요청을 보냈습니다. 현재 API 페이지 처리가 끝나면 안전하게 중단됩니다.', 'info');
startPolling();
} else {
showMessage('수집 작업을 취소했습니다.', 'success');
}
})
.withFailureHandler(function(error) {
handleServerError(error, '수집 취소');
})
.cancelCommentCollection();
}
function startPolling() {
if (pollTimer) {
return;
}
pollTimer = window.setInterval(function() {
refreshState(true);
}, 3000);
}
function stopPolling() {
if (pollTimer) {
window.clearInterval(pollTimer);
pollTimer = null;
}
}
function setButtonBusy(button, busy, label) {
button.disabled = busy;
if (busy) {
button.innerHTML = '<span class="row" style="justify-content:center;"><span class="spinner"></span><span>' +
escapeHtml(label) +
'</span></span>';
} else {
button.textContent = label;
}
}
function showMessage(text, type) {
const box = document.getElementById('message');
box.textContent = text || '';
box.className = 'message show ' + (type || 'info');
}
function clearMessage() {
const box = document.getElementById('message');
box.textContent = '';
box.className = 'message';
}
function getBadgeClass(status) {
if (status === 'COMPLETED') {
return 'success';
}
if (status === 'ERROR' || status === 'CANCELLED') {
return 'danger';
}
return 'warning';
}
function handleServerError(error, context) {
const errorInfo = parseServerError(error, context);
showErrorModal(errorInfo);
}
function parseServerError(error, context) {
if (error && error.title && error.summary) {
return normalizeClientErrorInfo(error);
}
const raw = error && error.message
? String(error.message)
: String(error || '');
const structured = extractStructuredError(raw);
if (structured) {
return normalizeClientErrorInfo(structured);
}
return buildFallbackErrorInfo(raw, context);
}
function extractStructuredError(rawText) {
const raw = String(rawText || '');
const markerIndex = raw.indexOf(ERROR_PREFIX);
if (markerIndex < 0) {
return null;
}
const objectStart = raw.indexOf(
'{',
markerIndex + ERROR_PREFIX.length
);
if (objectStart < 0) {
return null;
}
let depth = 0;
let inString = false;
let escaped = false;
for (let index = objectStart; index < raw.length; index += 1) {
const character = raw.charAt(index);
if (inString) {
if (escaped) {
escaped = false;
} else if (character === '\\') {
escaped = true;
} else if (character === '"') {
inString = false;
}
continue;
}
if (character === '"') {
inString = true;
continue;
}
if (character === '{') {
depth += 1;
} else if (character === '}') {
depth -= 1;
if (depth === 0) {
const jsonText = raw.slice(objectStart, index + 1);
try {
return JSON.parse(jsonText);
} catch (parseError) {
return null;
}
}
}
}
return null;
}
function normalizeClientErrorInfo(errorInfo) {
const info = errorInfo || {};
return {
code: String(info.code || 'UNKNOWN_ERROR'),
title: String(info.title || '작업을 완료하지 못했습니다.'),
summary: String(
info.summary ||
'처리 중 예상하지 못한 문제가 발생했습니다.'
),
causes: Array.isArray(info.causes)
? info.causes.map(String)
: [],
steps: Array.isArray(info.steps)
? info.steps.map(String)
: [],
helpLabel: String(info.helpLabel || ''),
helpUrl: String(info.helpUrl || ''),
focusTarget: String(info.focusTarget || ''),
actionLabel: String(info.actionLabel || '확인'),
technical:
info.technical && typeof info.technical === 'object'
? info.technical
: {}
};
}
function buildFallbackErrorInfo(rawText, context) {
const raw = String(rawText || '');
const lower = raw.toLowerCase();
const technical = {
errorCode: 'UNCLASSIFIED_CLIENT_ERROR',
context: String(context || '작업 실행')
};
if (
lower.indexOf('api key not valid') >= 0 ||
lower.indexOf('keyinvalid') >= 0 ||
lower.indexOf('api_key_invalid') >= 0
) {
return normalizeClientErrorInfo({
code: 'API_KEY_INVALID',
title: 'API 키를 사용할 수 없습니다.',
summary: '입력한 API 키가 정확하지 않거나 현재 사용할 수 없는 상태입니다.',
causes: [
'키 일부가 누락되었거나 다른 값을 붙여 넣었을 수 있습니다.',
'Google Cloud에서 기존 키를 삭제하거나 재발급했을 수 있습니다.'
],
steps: [
'Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.',
'실제 API 키 값을 전체 복사합니다.',
'사이드바에서 키를 다시 입력하고 저장합니다.'
],
helpLabel: 'API 키 관리 화면 열기',
helpUrl: 'https://console.cloud.google.com/apis/credentials',
focusTarget: 'apiKey',
actionLabel: 'API 키 다시 입력',
technical: Object.assign({}, technical, {
errorCode: 'API_KEY_INVALID'
})
});
}
if (
lower.indexOf('access not configured') >= 0 ||
lower.indexOf('accessnotconfigured') >= 0 ||
lower.indexOf('has not been used in project') >= 0 ||
lower.indexOf('api_disabled') >= 0
) {
return normalizeClientErrorInfo({
code: 'YOUTUBE_API_NOT_ENABLED',
title: 'YouTube Data API v3가 활성화되어 있지 않습니다.',
summary: 'API 키가 속한 Google Cloud 프로젝트에서 YouTube Data API v3 사용 설정을 켜야 합니다.',
causes: [
'API 키를 만든 프로젝트에서 YouTube Data API v3를 아직 활성화하지 않았습니다.',
'API를 활성화한 프로젝트와 API 키를 발급한 프로젝트가 다를 수 있습니다.'
],
steps: [
'Google Cloud Console에서 API 키를 만든 프로젝트를 선택합니다.',
'‘API 및 서비스 → 라이브러리’로 이동합니다.',
'‘YouTube Data API v3’를 검색하고 ‘사용’을 누릅니다.',
'설정 후 1~5분 뒤 API 키를 다시 저장합니다.'
],
helpLabel: 'YouTube Data API 설정 열기',
helpUrl: 'https://console.cloud.google.com/apis/library/youtube.googleapis.com',
focusTarget: 'apiKey',
actionLabel: '설정 확인',
technical: Object.assign({}, technical, {
errorCode: 'YOUTUBE_API_NOT_ENABLED'
})
});
}
if (
lower.indexOf('commentsdisabled') >= 0 ||
lower.indexOf('comments disabled') >= 0
) {
return normalizeClientErrorInfo({
code: 'COMMENTS_DISABLED',
title: '이 영상에서는 댓글을 수집할 수 없습니다.',
summary: '영상 게시자가 댓글 기능을 사용하지 않도록 설정했습니다.',
causes: [
'영상의 댓글 기능이 꺼져 있거나 YouTube 정책에 따라 제한되었습니다.'
],
steps: [
'영상 페이지에서 댓글 영역이 표시되는지 확인합니다.',
'댓글이 허용된 다른 영상 주소를 입력합니다.'
],
focusTarget: 'videoUrl',
actionLabel: '영상 주소 다시 입력',
technical: Object.assign({}, technical, {
errorCode: 'COMMENTS_DISABLED'
})
});
}
if (
lower.indexOf('quotaexceeded') >= 0 ||
lower.indexOf('quota_exceeded') >= 0 ||
lower.indexOf('daily limit') >= 0
) {
return normalizeClientErrorInfo({
code: 'YOUTUBE_QUOTA_EXCEEDED',
title: 'YouTube API의 오늘 할당량을 모두 사용했습니다.',
summary: '현재 프로젝트에서 오늘 사용할 수 있는 YouTube Data API 할당량이 남아 있지 않습니다.',
causes: [
'같은 프로젝트의 다른 작업에서도 YouTube API를 사용했을 수 있습니다.'
],
steps: [
'Google Cloud Console에서 YouTube Data API의 오늘 사용량을 확인합니다.',
'일일 할당량이 갱신된 뒤 다시 실행합니다.',
'다음 수집에서는 요청 댓글 수를 줄입니다.'
],
helpLabel: 'YouTube API 할당량 열기',
helpUrl: 'https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas',
actionLabel: '확인',
technical: Object.assign({}, technical, {
errorCode: 'YOUTUBE_QUOTA_EXCEEDED'
})
});
}
if (
lower.indexOf('authorization is required') >= 0 ||
lower.indexOf('you do not have permission') >= 0
) {
return normalizeClientErrorInfo({
code: 'APPS_SCRIPT_AUTH_REQUIRED',
title: 'Google 권한 승인이 필요합니다.',
summary: '현재 계정이 스프레드시트 또는 외부 API 요청 권한을 아직 승인하지 않았습니다.',
causes: [
'Apps Script를 처음 실행했거나 새로운 권한이 추가되었습니다.'
],
steps: [
'스프레드시트 메뉴에서 사이드바를 다시 엽니다.',
'권한 검토 화면에서 현재 계정을 선택합니다.',
'요청되는 권한을 허용한 뒤 다시 실행합니다.'
],
actionLabel: '확인',
technical: Object.assign({}, technical, {
errorCode: 'APPS_SCRIPT_AUTH_REQUIRED'
})
});
}
if (
lower.indexOf('timed out') >= 0 ||
lower.indexOf('maximum execution time') >= 0
) {
return normalizeClientErrorInfo({
code: 'APPS_SCRIPT_EXECUTION_TIMEOUT',
title: '한 번의 실행 가능 시간을 초과했습니다.',
summary: '댓글이 많아 현재 실행 구간이 종료되었습니다.',
causes: [
'대량 댓글 또는 답글 처리 시간이 길어졌습니다.'
],
steps: [
'진행 상태가 자동으로 이어지는지 확인합니다.',
'멈춘 경우 ‘중단 지점부터 실행’을 한 번 실행합니다.',
'반복되면 수집 개수를 줄입니다.'
],
actionLabel: '확인',
technical: Object.assign({}, technical, {
errorCode: 'APPS_SCRIPT_EXECUTION_TIMEOUT'
})
});
}
return normalizeClientErrorInfo({
code: 'UNEXPECTED_ERROR',
title: '예상하지 못한 오류가 발생했습니다.',
summary: '오류 유형을 자동으로 판별하지 못했습니다. 입력값과 Apps Script 실행 기록을 확인해야 합니다.',
causes: [
'네트워크 연결, Google 서비스 상태, 권한 또는 실행 환경에서 문제가 발생했을 수 있습니다.'
],
steps: [
'사이드바를 닫았다가 다시 열고 같은 작업을 한 번 더 실행합니다.',
'Apps Script 편집기의 왼쪽 ‘실행’ 메뉴에서 실패 기록을 확인합니다.',
'API 키 저장 상태와 영상 주소를 다시 확인합니다.'
],
actionLabel: '확인',
technical: technical
});
}
function showErrorModal(errorInfo) {
const info = normalizeClientErrorInfo(errorInfo);
const backdrop = document.getElementById('errorModalBackdrop');
const title = document.getElementById('errorModalTitle');
const code = document.getElementById('errorModalCode');
const summary = document.getElementById('errorModalSummary');
const causeSection = document.getElementById('errorCauseSection');
const stepSection = document.getElementById('errorStepSection');
const causeList = document.getElementById('errorCauseList');
const stepList = document.getElementById('errorStepList');
const helpLink = document.getElementById('errorHelpLink');
const primaryButton = document.getElementById('errorModalPrimaryButton');
clearMessage();
title.textContent = info.title;
code.textContent = '오류 코드: ' + info.code;
summary.textContent = info.summary;
renderTextList(causeList, info.causes);
renderTextList(stepList, info.steps);
causeSection.classList.toggle('hidden', info.causes.length === 0);
stepSection.classList.toggle('hidden', info.steps.length === 0);
if (
info.helpUrl &&
/^https:\/\/console\.cloud\.google\.com\//.test(info.helpUrl)
) {
helpLink.href = info.helpUrl;
helpLink.textContent = info.helpLabel || 'Google Cloud 설정 열기';
helpLink.classList.remove('hidden');
} else {
helpLink.removeAttribute('href');
helpLink.textContent = '';
helpLink.classList.add('hidden');
}
primaryButton.textContent = info.actionLabel || '확인';
pendingFocusTarget = info.focusTarget || '';
renderTechnicalInfo(info.technical);
backdrop.classList.remove('hidden');
document.body.style.overflow = 'hidden';
window.setTimeout(function() {
primaryButton.focus();
}, 0);
}
function renderTextList(listElement, items) {
listElement.innerHTML = '';
(items || []).forEach(function(item) {
const listItem = document.createElement('li');
listItem.textContent = String(item);
listElement.appendChild(listItem);
});
}
function renderTechnicalInfo(technical) {
const details = document.getElementById('errorTechnicalDetails');
const grid = document.getElementById('errorTechnicalGrid');
const data = technical && typeof technical === 'object'
? technical
: {};
const labels = {
errorCode: '분류 코드',
httpStatus: 'HTTP 상태',
reason: 'API 사유 코드',
resource: '요청 자원',
videoId: '영상 ID',
context: '실행 단계'
};
grid.innerHTML = '';
Object.keys(labels).forEach(function(key) {
if (
data[key] === undefined ||
data[key] === null ||
data[key] === ''
) {
return;
}
const label = document.createElement('div');
label.className = 'technical-label';
label.textContent = labels[key];
const value = document.createElement('div');
value.className = 'technical-value';
value.textContent = String(data[key]);
grid.appendChild(label);
grid.appendChild(value);
});
details.classList.toggle('hidden', grid.children.length === 0);
details.open = false;
}
function closeErrorModal() {
const backdrop = document.getElementById('errorModalBackdrop');
if (backdrop.classList.contains('hidden')) {
return;
}
backdrop.classList.add('hidden');
document.body.style.overflow = '';
pendingFocusTarget = '';
}
function handleErrorModalPrimaryAction() {
const focusTarget = pendingFocusTarget;
const backdrop = document.getElementById('errorModalBackdrop');
backdrop.classList.add('hidden');
document.body.style.overflow = '';
pendingFocusTarget = '';
focusErrorTarget(focusTarget);
}
function focusErrorTarget(targetId) {
if (!targetId) {
return;
}
if (targetId === 'apiKey') {
document.getElementById('apiSavedView').classList.add('hidden');
document.getElementById('apiMissingView').classList.remove('hidden');
}
const target = document.getElementById(targetId);
if (target) {
window.setTimeout(function() {
target.focus();
if (typeof target.select === 'function') {
target.select();
}
}, 0);
}
}
function formatNumber(value) {
return Number(value || 0).toLocaleString('ko-KR');
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
</script>
</body>
</html>