2026 AI·디지털 교육 역량강화 연수(국어 기초) - 8. 11. 공주대학교 천안캠퍼스
- 강사
- 교사 김민기(민기쌤)
- 일시
- 2026-08-11
- 장소
- 공주대학교 8공학관
충청남도교육청 주관 연수입니다.
관련 링크
(1차시) 마주온 활용
교안 및 학습자료
(2차시) 토의·토론
교안 및 학습자료
(3차시) AI 융합 수업 설계
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>Youtube Data API v3 KEY
AIzaSyDETqF5gzbFh3DG4U0N-rjT1yK2asM-1X4
교안 및 학습자료
관련 링크
(4차시) 앱스스크립트
교안 및 학습자료
(5차시) 바른 API로 생기부 검토
Code.gs
// =================================================================
// [0] 초기 설정 및 메뉴
// =================================================================
function onOpen() {
SpreadsheetApp.getUi().createMenu('민기쌤 생기부 도우미')
.addItem('🚀 사이드바 열기', 'openSidebar')
.addToUi();
}
function renderView_(viewName) {
var template = HtmlService.createTemplateFromFile('Sidebar');
template.initialView = viewName || 'sidebar';
return template.evaluate();
}
function openSidebar() {
ensureBannedWordSheet_();
var html = renderView_('sidebar')
.setTitle('민기쌤 생기부 맞춤법 도우미')
.setWidth(300);
SpreadsheetApp.getUi().showSidebar(html);
}
function openSpellCheckViewer() {
var html = renderView_('spellcheck')
.setWidth(1000)
.setHeight(700);
SpreadsheetApp.getUi().showModelessDialog(html, '📝 Bareun 맞춤법 검사 및 수정');
}
function openBannedWordViewer() {
var html = renderView_('banned')
.setWidth(1000)
.setHeight(700);
SpreadsheetApp.getUi().showModelessDialog(html, '🚫 기재 금지어 점검');
}
// =================================================================
// [1] Bareun API 키 및 UI 설정
// =================================================================
function getBareunUserProperties_() {
ensureBannedWordSheet_();
var props = PropertiesService.getUserProperties();
var currentPolicy = 'BAREUN_ONLY_KEYS_V3';
var previousPolicy = props.getProperty('API_KEY_POLICY_VERSION');
// 정책 변경 시 현재 기능에서 쓰지 않는 예전 사용자 설정을 한 번 정리합니다.
if (previousPolicy !== currentPolicy) {
var allowedProperties = {
UI_STATE: true,
API_KEY_POLICY_VERSION: true
};
// 바로 이전 사용자 입력형 정책에서 저장한 키만 보존합니다.
if (previousPolicy === 'USER_SUPPLIED_KEYS_V1' ||
previousPolicy === 'BAREUN_ONLY_KEYS_V1' ||
previousPolicy === 'BAREUN_ONLY_KEYS_V2') {
allowedProperties.USER_BAREUN_KEY = true;
}
Object.keys(props.getProperties()).forEach(function(propertyName) {
if (!allowedProperties[propertyName]) props.deleteProperty(propertyName);
});
var previousUiState = {};
try {
previousUiState = JSON.parse(props.getProperty('UI_STATE') || '{}');
} catch (e) {
previousUiState = {};
}
var currentUiState = {};
['card-api', 'sub-api-settings', 'card-util'].forEach(function(id) {
if (previousUiState[id]) currentUiState[id] = true;
});
props.setProperty('UI_STATE', JSON.stringify(currentUiState));
props.setProperty('API_KEY_POLICY_VERSION', currentPolicy);
}
return props;
}
function saveUserBareunKey(bareunKey) {
var props = getBareunUserProperties_();
var cleanBareunKey = String(bareunKey || '').trim();
if (!cleanBareunKey) {
throw new Error('Bareun API 키를 입력해 주세요. 이 도우미는 API 키를 제공하지 않습니다.');
}
props.setProperty('USER_BAREUN_KEY', cleanBareunKey);
return { hasBareun: true };
}
function checkUserBareunStatus() {
var props = getBareunUserProperties_();
return { hasBareun: !!props.getProperty('USER_BAREUN_KEY') };
}
function clearUserBareunKey() {
var props = getBareunUserProperties_();
props.deleteProperty('USER_BAREUN_KEY');
return { hasBareun: false };
}
function getBareunKey() {
var key = getBareunUserProperties_().getProperty('USER_BAREUN_KEY');
if (!key) throw new Error("Bareun API 키가 없습니다. 사이드바 상단에서 등록해주세요.");
return key;
}
// =================================================================
// [2] Bareun 맞춤법 검사
// =================================================================
function getSelectedDataForSpellCheck() {
var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
var sheetName = sheet.getName();
var range = sheet.getActiveRange();
if (!range) throw new Error("선택된 셀이 없습니다.");
var rows = [];
var startRow = range.getRow();
var startCol = range.getColumn();
var values = range.getValues();
for (var i = 0; i < values.length; i++) {
for (var j = 0; j < values[0].length; j++) {
var text = values[i][j].toString();
if (text.trim() !== "") {
rows.push({ sheetName: sheetName, row: startRow + i, col: startCol + j, original: text });
}
}
}
return rows;
}
function getBareunCorrection(text) {
var userKey = getBareunKey();
var result = callBareunApi(text, userKey);
return result;
}
function applySpellCheckResults(updates) {
var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
updates.forEach(function(item) {
var sheet = spreadsheet.getSheetByName(item.sheetName);
if (!sheet) throw new Error('검사한 시트를 찾을 수 없습니다: ' + item.sheetName);
sheet.getRange(item.row, item.col).setValue(item.text);
});
return updates.length;
}
function callBareunApi(text, userKey) {
var url = "https://api.bareun.ai/bareun.RevisionService/CorrectError";
var options = {
"method": "post", "contentType": "application/json", "headers": { "api-key": userKey }, "muteHttpExceptions": true,
"payload": JSON.stringify({ "document": { "content": text, "language": "ko-KR" }, "encoding_type": "UTF16" })
};
var response = UrlFetchApp.fetch(url, options);
var result = JSON.parse(response.getContentText());
if (response.getResponseCode() !== 200) {
return { error: "API 오류: " + response.getResponseCode() };
}
return { revised: result.revised, helps: result.helps || {}, rawBlocks: result.revisedBlocks || result.revised_blocks || [], error: null };
}
// =================================================================
// [3] 기재 금지어 관리
// =================================================================
var BANNED_WORD_SHEET_NAME_ = '기재금지어관리';
var BANNED_WORD_HEADERS_ = ['번호', '기재 금지어'];
var LEGACY_BANNED_WORD_HEADERS_ = ['유형', '기재 금지어', '대체 표현'];
var ONE_COLUMN_BANNED_WORD_HEADERS_ = ['기재 금지어'];
function getLegacyBannedWordList_() {
var saved = PropertiesService.getUserProperties().getProperty('BANNED_WORDS_DB');
if (!saved) return getDefaultBannedList_();
try {
var parsed = JSON.parse(saved);
if (!Array.isArray(parsed)) return getDefaultBannedList_();
return parsed
.map(function(item) {
return String(item && item.k || '').trim();
})
.filter(function(keyword) { return keyword !== ''; });
} catch (e) {
return getDefaultBannedList_();
}
}
function initializeBannedWordSheet_(sheet, list) {
sheet.getRange(1, 1, 1, BANNED_WORD_HEADERS_.length)
.setValues([BANNED_WORD_HEADERS_])
.setBackground('#fce8e6')
.setFontColor('#a50e0e')
.setFontWeight('bold')
.setHorizontalAlignment('center');
var rows = list.map(function(keyword, index) {
return [index + 1, keyword];
});
if (rows.length > 0) sheet.getRange(2, 1, rows.length, 2).setValues(rows);
sheet.setFrozenRows(1);
sheet.setColumnWidth(1, 60);
sheet.setColumnWidth(2, 260);
sheet.setTabColor('#d93025');
}
function migrateLegacyBannedWordSheet_(sheet) {
var lastColumn = sheet.getLastColumn();
var legacyColumn;
var clearColumns;
if (lastColumn >= 3 && sheet.getRange(1, 1, 1, 3).getDisplayValues()[0].join('|') === LEGACY_BANNED_WORD_HEADERS_.join('|')) {
legacyColumn = 2;
clearColumns = 3;
} else if (sheet.getRange(1, 1, 1, 1).getDisplayValues()[0][0] === ONE_COLUMN_BANNED_WORD_HEADERS_[0]) {
legacyColumn = 1;
clearColumns = 1;
} else {
return false;
}
var lastRow = sheet.getLastRow();
var keywords = lastRow < 2 ? [] : sheet.getRange(2, legacyColumn, lastRow - 1, 1)
.getDisplayValues()
.map(function(row) { return String(row[0] || '').trim(); })
.filter(function(keyword) { return keyword !== ''; });
var clearRows = Math.max(sheet.getLastRow(), 1);
sheet.getRange(1, 1, clearRows, clearColumns).clearContent();
initializeBannedWordSheet_(sheet, keywords);
return true;
}
function ensureBannedWordSheet_() {
var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
if (!spreadsheet) throw new Error('활성 스프레드시트를 찾을 수 없습니다.');
var originalSheet = spreadsheet.getActiveSheet();
var originalRange = spreadsheet.getActiveRange();
var lock = LockService.getDocumentLock() || LockService.getScriptLock();
lock.waitLock(30000);
var sheet;
try {
sheet = spreadsheet.getSheetByName(BANNED_WORD_SHEET_NAME_);
if (!sheet) {
sheet = spreadsheet.insertSheet(BANNED_WORD_SHEET_NAME_);
initializeBannedWordSheet_(sheet, getLegacyBannedWordList_());
} else {
// 이전 형식은 금지어만 보존하여 번호·기재금지어 두 열 목록으로 안전하게 바꿉니다.
migrateLegacyBannedWordSheet_(sheet);
}
// 시트로 옮긴 뒤 예전 사용자별 저장값은 더 이상 별도 원본으로 남기지 않습니다.
PropertiesService.getUserProperties().deleteProperty('BANNED_WORDS_DB');
return sheet;
} finally {
if (originalSheet && sheet && originalSheet.getSheetId() !== sheet.getSheetId()) {
spreadsheet.setActiveSheet(originalSheet);
if (originalRange) spreadsheet.setActiveRange(originalRange);
}
lock.releaseLock();
}
}
function openBannedWordManagementSheet() {
var sheet = ensureBannedWordSheet_();
sheet.activate();
sheet.getRange('A1').activate();
return sheet.getName();
}
function getDefaultBannedList_() {
return [
"MBTI", "커리어넷", "메이저맵", "패들렛", "띵커벨", "알로",
"미리캔버스", "망고보드", "유튜버", "유튜브", "넷플릭스", "왓챠",
"티빙", "네이버 폼", "구글 폼", "OECD", "UN", "유네스코", "WTO",
"WHO", "그린피스", "앰네스티", "IMF", "인스타그램", "페이스북",
"카카오톡", "네이버", "구글", "클래스 카드", "구글 클래스룸", "게더타운",
"제페토", "아이패드", "갤럭시 탭", "비트코인", "TED", "반크", "TOEIC",
"TOEFL", "TEPS", "대회", "수상", "한자능력검정"
];
}
function getBannedWordList_() {
var sheet = ensureBannedWordSheet_();
var header = sheet.getRange(1, 1, 1, 2).getDisplayValues()[0];
if (header.join('|') !== BANNED_WORD_HEADERS_.join('|')) {
throw new Error('기재금지어관리 시트의 1행은 "번호 | 기재 금지어" 순서여야 합니다.');
}
var lastRow = sheet.getLastRow();
if (lastRow < 2) return [];
var values = sheet.getRange(2, 1, lastRow - 1, 2).getDisplayValues();
var seen = {};
var list = [];
values.forEach(function(row, index) {
var rowNumber = index + 2;
var number = String(row[0] || '').trim();
var keyword = String(row[1] || '').trim();
if (!number && !keyword) return;
if (!/^[1-9]\d*$/.test(number)) {
throw new Error('기재금지어관리 시트 ' + rowNumber + '행의 번호(A열)는 1 이상의 정수여야 합니다.');
}
if (!keyword) throw new Error('기재금지어관리 시트 ' + rowNumber + '행의 기재 금지어(B열)를 입력해 주세요.');
var duplicateKey = normalizeBannedWord_(keyword);
if (seen[duplicateKey]) {
throw new Error('기재금지어관리 시트 ' + rowNumber + '행의 기재 금지어가 ' + seen[duplicateKey] + '행과 중복됩니다: ' + keyword);
}
seen[duplicateKey] = rowNumber;
list.push(keyword);
});
return list;
}
function runBannedWordCheck() {
var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
var sheetName = sheet.getName();
var range = sheet.getActiveRange();
if (!range) throw new Error("선택된 셀이 없습니다.");
var values = range.getValues();
var startRow = range.getRow();
var startCol = range.getColumn();
var db = getBannedWordList_();
if (db.length === 0) return [];
db.sort(function(a, b) { return normalizeBannedWord_(b).length - normalizeBannedWord_(a).length; });
var canonicalByNormalizedKeyword = {};
db.forEach(function(keyword) { canonicalByNormalizedKeyword[normalizeBannedWord_(keyword)] = keyword; });
var combinedPattern = new RegExp('(' + db.map(buildWhitespaceInsensitivePattern_).join('|') + ')', 'gi');
var results = [];
for (var i = 0; i < values.length; i++) {
for (var j = 0; j < values[0].length; j++) {
var original = values[i][j].toString();
if (!original.trim()) continue;
var hasIssue = false;
var logs = [];
var counts = {};
var match;
while ((match = combinedPattern.exec(original)) !== null) {
var normalizedMatch = normalizeBannedWord_(match[0]);
var keyword = canonicalByNormalizedKeyword[normalizedMatch];
if (keyword) {
hasIssue = true;
counts[keyword] = (counts[keyword] || 0) + 1;
}
}
Object.keys(counts).forEach(function(keyword) {
logs.push({ k: keyword, count: counts[keyword] });
});
if (hasIssue) {
results.push({
sheetName: sheetName,
row: startRow + i,
col: startCol + j,
original: original,
logs: logs
});
}
}
}
return results;
}
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function normalizeBannedWord_(value) {
return String(value || '').replace(/\s+/g, '').toLocaleLowerCase();
}
function buildWhitespaceInsensitivePattern_(keyword) {
var compact = normalizeBannedWord_(keyword);
if (!compact) throw new Error('비어 있는 기재 금지어는 검사할 수 없습니다.');
return compact.split('').map(function(character) {
return escapeRegExp(character);
}).join('[\\s\\u00A0]*');
}
// =================================================================
// [4] 로컬 검토 유틸리티
// =================================================================
function unifyToStraightQuotes() {
var range = SpreadsheetApp.getActiveRange();
var ui = SpreadsheetApp.getUi();
if (!range) throw new Error('선택된 셀이 없습니다.');
var response = ui.alert('따옴표 변환', '선택 영역의 둥근 따옴표를 곧은 따옴표로 변환하시겠습니까?', ui.ButtonSet.YES_NO);
if (response != ui.Button.YES) return;
try {
range.createTextFinder('[‘’]').useRegularExpression(true).matchEntireCell(false).replaceAllWith("'");
range.createTextFinder('[“”]').useRegularExpression(true).matchEntireCell(false).replaceAllWith('"');
SpreadsheetApp.flush();
ui.alert('✅ 변환 완료');
} catch (e) { ui.alert('오류: ' + e.toString()); }
}
// [신규] 선택 영역 스페이스바 실수(2칸 이상) 수정 함수
function fixDoubleSpaces() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
const range = sheet.getActiveRange();
if (!range) throw new Error("선택된 셀이 없습니다.");
const values = range.getValues();
let fixCount = 0; // 수정된 셀 개수 카운트
const newValues = values.map(row => {
return row.map(cell => {
let str = String(cell);
// 공백이 2번 이상 연속( / +/)으로 있으면 1번으로 변경
// 줄바꿈(\n)은 건드리지 않도록 일반 공백( )만 타겟팅
if (/ {2,}/.test(str)) {
fixCount++;
return str.replace(/ {2,}/g, ' ');
}
return cell;
});
});
if (fixCount > 0) {
range.setValues(newValues);
}
return fixCount;
}
// =================================================================
// [5] UI 상태 저장/불러오기
// =================================================================
function saveUiState(cardId, isExpanded) {
var props = PropertiesService.getUserProperties();
var state = JSON.parse(props.getProperty('UI_STATE') || '{}');
state[cardId] = isExpanded;
props.setProperty('UI_STATE', JSON.stringify(state));
}
function getUiState() {
var props = PropertiesService.getUserProperties();
return JSON.parse(props.getProperty('UI_STATE') || '{}');
}
Sidebar.html
<? var allowedViews = ["sidebar", "spellcheck", "banned"]; ?>
<? var activeView = allowedViews.indexOf(initialView) >= 0 ? initialView : "sidebar"; ?>
<? if (activeView === 'sidebar') { ?>
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
padding: 10px 15px 10px 2px;
background: #f1f3f4;
color: #202124;
font-family: 'Noto Sans KR', sans-serif;
}
.card {
width: 100%;
margin: 0 0 16px;
overflow: hidden;
border-radius: 12px;
background: #fff;
box-shadow: 0 2px 6px rgba(60,64,67,.15);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 20px;
cursor: pointer;
user-select: none;
border-bottom: 1px solid transparent;
}
.card-header:hover { background: #f8f9fa; }
.card.expanded .card-header { border-bottom-color: #f1f3f4; }
.header-title { font-size: 15px; font-weight: 700; }
.toggle-icon { color: #5f6368; transition: transform .2s; }
.card.expanded .toggle-icon { transform: rotate(180deg); }
.card-body { display: none; padding: 20px; }
.card.expanded .card-body { display: block; }
.sub-section { overflow: hidden; border: 1px solid #eee; border-radius: 8px; }
.sub-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 15px;
background: #f8f9fa;
color: #3c4043;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.sub-body { display: none; padding: 15px; }
.sub-section.expanded .sub-body { display: block; }
.sub-icon { color: #5f6368; font-size: 18px; transition: transform .2s; }
.sub-section.expanded .sub-icon { transform: rotate(180deg); }
.hidden { display: none !important; }
.input-label { display: block; margin-bottom: 5px; color: #5f6368; font-size: 12px; font-weight: 600; }
input[type="password"] {
width: 100%;
padding: 10px;
border: 2px solid #dadce0;
border-radius: 6px;
outline: none;
font-family: inherit;
font-size: 14px;
}
input[type="password"]:focus { border-color: #1a73e8; }
button {
width: 100%;
margin-top: 6px;
padding: 12px 0;
border: 0;
border-radius: 8px;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
button:disabled { cursor: not-allowed; background: #dadce0 !important; color: #5f6368 !important; }
.btn-auth { background: #34a853; color: #fff; }
.btn-primary { background: #1a73e8; color: #fff; }
.btn-warning { background: #fbbc04; color: #202124; }
.btn-secondary { margin-bottom: 8px; border: 1px solid #dadce0; background: #fff; color: #3c4043; }
.btn-secondary:hover { border-color: #1a73e8; color: #1a73e8; }
.api-status { margin-bottom: 12px; padding: 10px; border-radius: 8px; background: #f8f9fa; font-size: 12px; }
.api-status .ok { color: #188038; font-weight: 700; }
.api-status .missing { color: #c5221f; font-weight: 700; }
.key-help { margin: 7px 0 10px; color: #5f6368; font-size: 11px; line-height: 1.5; }
.status-box { min-height: 18px; margin-top: 8px; color: #e37400; font-size: 12px; text-align: center; }
</style>
</head>
<body>
<div class="card" id="card-api">
<div class="card-header" onclick="toggleCard(this)">
<div class="header-title">🔑 Bareun API 키 설정</div>
<span class="material-icons toggle-icon">expand_more</span>
</div>
<div class="card-body">
<div class="sub-section" id="sub-api-settings">
<div class="sub-header" onclick="toggleSub(this)">
<span>내 Bareun API 키</span>
<span class="material-icons sub-icon">expand_more</span>
</div>
<div class="sub-body">
<div id="api-loading" class="api-status">⌛ API 키 설정 확인 중...</div>
<div id="api-settings" class="hidden">
<div id="apiStatusSummary" class="api-status"></div>
<label class="input-label" for="bareunKeyInput">Bareun API 키</label>
<input type="password" id="bareunKeyInput" autocomplete="off" placeholder="본인이 발급받은 키를 입력하세요">
<div class="key-help">공용 키는 제공하지 않습니다. 키는 사용자별 설정에 저장되고 원문을 화면으로 다시 불러오지 않습니다.</div>
<button class="btn-auth" id="saveKeyBtn" onclick="saveBareunKey()">내 Bareun 키 저장</button>
<button class="btn-secondary hidden" id="clearKeyBtn" onclick="resetBareunKey()">저장된 Bareun 키 삭제</button>
</div>
</div>
</div>
</div>
</div>
<div class="card expanded" id="card-util">
<div class="card-header" onclick="toggleCard(this)">
<div class="header-title">📝 선택 영역 검사 및 정리</div>
<span class="material-icons toggle-icon">expand_more</span>
</div>
<div class="card-body">
<button class="btn-warning" id="checkBtn" onclick="runSpellCheck()">선택 영역 Bareun 맞춤법 검사 🔍</button>
<div id="checkStatus" class="status-box"></div>
<button class="btn-secondary" onclick="runSpaceFix(this)">선택 영역 연속 공백 정리</button>
<button class="btn-secondary" onclick="convertQuotes(this)">선택 영역 따옴표 통일 (' ")</button>
<button class="btn-secondary" onclick="runBannedCheck()">선택 영역 기재 금지어 검사</button>
<button class="btn-secondary" onclick="openBannedWordManagement()">기재금지어관리 시트 열기</button>
</div>
</div>
<script>
let currentApiStatus = { hasBareun: false };
function updateApiState(status) {
currentApiStatus = { hasBareun: !!(status && status.hasBareun) };
const summary = document.getElementById('apiStatusSummary');
const clearButton = document.getElementById('clearKeyBtn');
const spellCheckButton = document.getElementById('checkBtn');
const apiCard = document.getElementById('card-api');
const apiSub = document.getElementById('sub-api-settings');
summary.innerHTML = currentApiStatus.hasBareun
? '<div class="ok">✅ Bareun 키 저장됨</div>'
: '<div class="missing">❌ 맞춤법 검사를 사용하려면 Bareun 키가 필요합니다.</div>';
clearButton.classList.toggle('hidden', !currentApiStatus.hasBareun);
spellCheckButton.disabled = !currentApiStatus.hasBareun;
spellCheckButton.title = currentApiStatus.hasBareun ? '' : 'Bareun API 키를 먼저 저장해 주세요.';
apiCard.classList.toggle('expanded', !currentApiStatus.hasBareun);
apiSub.classList.toggle('expanded', !currentApiStatus.hasBareun);
}
function toggleCard(header) {
const card = header.parentElement;
const isExpanded = card.classList.toggle('expanded');
google.script.run.saveUiState(card.id, isExpanded);
}
function toggleSub(header) {
const section = header.parentElement;
const isExpanded = section.classList.toggle('expanded');
google.script.run.saveUiState(section.id, isExpanded);
}
function saveBareunKey() {
const button = document.getElementById('saveKeyBtn');
const keyInput = document.getElementById('bareunKeyInput');
const key = keyInput.value.trim();
if (!key) {
alert('Bareun API 키를 입력해 주세요.');
return;
}
const originalText = button.innerText;
button.disabled = true;
button.innerText = '저장 중...';
google.script.run
.withSuccessHandler(function(status) {
keyInput.value = '';
updateApiState(status);
button.disabled = false;
button.innerText = originalText;
alert('✅ Bareun API 키를 사용자 설정에 저장했습니다.');
})
.withFailureHandler(function(error) {
button.disabled = false;
button.innerText = originalText;
alert('Bareun API 키 저장 오류: ' + error);
})
.saveUserBareunKey(key);
}
function resetBareunKey() {
if (!confirm('저장된 Bareun API 키를 삭제하시겠습니까?')) return;
google.script.run
.withSuccessHandler(function(status) {
document.getElementById('bareunKeyInput').value = '';
updateApiState(status);
alert('저장된 Bareun API 키를 삭제했습니다.');
})
.withFailureHandler(function(error) {
alert('Bareun API 키 삭제 오류: ' + error);
})
.clearUserBareunKey();
}
function runSpellCheck() {
if (!currentApiStatus.hasBareun) {
alert('Bareun API 키를 먼저 저장해 주세요.');
return;
}
document.getElementById('checkStatus').innerText = '맞춤법 검사 창을 여는 중...';
google.script.run
.withSuccessHandler(function() {
document.getElementById('checkStatus').innerText = '';
})
.withFailureHandler(function(error) {
document.getElementById('checkStatus').innerText = '';
alert('오류 발생: ' + error);
})
.openSpellCheckViewer();
}
function runSpaceFix(button) {
const originalText = button.innerText;
button.disabled = true;
button.innerText = '정리 중...';
google.script.run
.withSuccessHandler(function(count) {
button.disabled = false;
button.innerText = originalText;
alert(count ? '✅ 총 ' + count + '개 셀의 연속 공백을 정리했습니다.' : '수정할 연속 공백이 없습니다.');
})
.withFailureHandler(function(error) {
button.disabled = false;
button.innerText = originalText;
alert('오류 발생: ' + error);
})
.fixDoubleSpaces();
}
function convertQuotes(button) {
const originalText = button.innerText;
button.disabled = true;
button.innerText = '변환 중...';
google.script.run
.withSuccessHandler(function() {
button.disabled = false;
button.innerText = originalText;
})
.withFailureHandler(function(error) {
button.disabled = false;
button.innerText = originalText;
alert('오류 발생: ' + error);
})
.unifyToStraightQuotes();
}
function runBannedCheck() {
google.script.run
.withFailureHandler(function(error) { alert('오류 발생: ' + error); })
.openBannedWordViewer();
}
function openBannedWordManagement() {
google.script.run
.withFailureHandler(function(error) { alert('오류 발생: ' + error); })
.openBannedWordManagementSheet();
}
window.onload = function() {
google.script.run
.withSuccessHandler(function(status) {
document.getElementById('api-loading').classList.add('hidden');
document.getElementById('api-settings').classList.remove('hidden');
updateApiState(status);
google.script.run.withSuccessHandler(function(states) {
Object.keys(states || {}).forEach(function(id) {
const element = document.getElementById(id);
if (id === 'card-api' || id === 'sub-api-settings') return;
if (element && states[id]) element.classList.add('expanded');
});
updateApiState(status);
}).getUiState();
})
.withFailureHandler(function(error) {
document.getElementById('api-loading').classList.add('hidden');
document.getElementById('api-settings').classList.remove('hidden');
updateApiState({ hasBareun: false });
document.getElementById('apiStatusSummary').textContent = '설정 확인 오류: ' + error;
})
.checkUserBareunStatus();
};
</script>
</body>
</html>
<? } else if (activeView === 'spellcheck') { ?>
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch.js"></script>
<style>
body { padding: 20px; font-family: 'Noto Sans KR', sans-serif; background: #f8f9fa; margin: 0; height: 100vh; display: flex; flex-direction: column; box-sizing: border-box; }
.header-area { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.title { font-size: 20px; font-weight: 700; color: #1a73e8; display: flex; align-items: center; gap: 8px; }
.content-area { flex: 1; overflow-y: auto; padding-right: 10px; position: relative; }
.check-item { background: white; border: 1px solid #dadce0; border-radius: 8px; margin-bottom: 25px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden; }
.item-header { background: #f1f3f4; padding: 10px 15px; font-size: 14px; font-weight: 700; color: #3c4043; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }
.split-view { display: flex; min-height: 120px; }
.pane {
flex: 1; padding: 15px; font-size: 14px; line-height: 1.8; color: #3c4043;
white-space: pre-wrap; word-break: keep-all; text-align: justify;
}
.pane-left { border-right: 1px solid #eee; background: #fff; }
.pane-right { background: #f8fbff; outline: none; }
.pane-right:focus { background: #fff; box-shadow: inset 0 0 0 2px #1a73e8; }
/* [하이라이트 스타일] */
.diff-del {
background-color: #ffe6e6; color: #c5221f; border-bottom: 2px solid #c5221f;
text-decoration: none; padding: 1px 0; font-weight: 500;
}
.diff-add {
background-color: #e6f4ea; color: #188038; border-bottom: 2px solid #188038;
text-decoration: none; padding: 1px 0; font-weight: 500;
}
.analysis-box { border-top: 1px solid #eee; background: #fafafa; padding: 10px 15px; font-size: 12px; }
.analysis-title { font-weight: bold; color: #5f6368; margin-bottom: 8px; display: flex; align-items: center; gap: 5px; }
.analysis-list { list-style: none; padding: 0; margin: 0; }
.analysis-row { display: flex; gap: 10px; padding: 6px 0; border-bottom: 1px dashed #eee; align-items: center; }
.analysis-row:last-child { border-bottom: none; }
.err-word { color: #d93025; text-decoration: line-through; }
.arrow { color: #999; font-size: 10px; }
.fix-word { color: #188038; font-weight: bold; }
.fix-reason { color: #1a73e8; margin-left: auto; background: #e8f0fe; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
.byte-info { display: flex; border-top: 1px solid #eee; font-size: 12px; color: #5f6368; background: #fff; }
.byte-box { flex: 1; padding: 5px 15px; text-align: right; }
.byte-val { font-weight: bold; color: #1a73e8; }
.footer-bar { margin-top: 15px; padding-top: 15px; border-top: 1px solid #dadce0; display: flex; justify-content: flex-end; gap: 10px; }
.btn { padding: 10px 20px; border-radius: 4px; border: none; font-weight: bold; cursor: pointer; font-size: 14px; }
.btn-cancel { background: #f1f3f4; color: #3c4043; }
.btn-apply { background: #1a73e8; color: white; box-shadow: 0 2px 5px rgba(26,115,232,0.3); }
.btn-apply:hover { background: #1765cc; }
.btn:disabled { background: #dadce0; cursor: not-allowed; box-shadow: none; color: #fff; }
.loader-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: white; z-index: 100; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #5f6368; font-size: 14px; }
.spinner { border: 4px solid #f3f3f3; border-top: 4px solid #1a73e8; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin-bottom: 15px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.error-msg { color: #d93025; font-weight: bold; padding: 20px; text-align: center; background: #fce8e6; border-radius: 8px; }
</style>
</head>
<body>
<div class="header-area">
<div class="title"><span class="material-icons">fact_check</span> 맞춤법 검사 및 수정</div>
<div style="font-size:12px; color:#5f6368; display:flex; gap:15px;">
<div><span style="background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;">원본 오류</span></div>
<div><span style="background:#e6f4ea; color:#188038; border-bottom:2px solid #188038; padding:0 4px; font-weight:500;">수정 제안</span></div>
</div>
</div>
<div id="mainContent" class="content-area">
<div id="loaderOverlay" class="loader-overlay">
<div class="spinner"></div>
<div id="loadingText">데이터를 불러오는 중입니다...</div>
</div>
<div id="cardsContainer"></div>
</div>
<div class="footer-bar">
<button class="btn btn-cancel" onclick="google.script.host.close()">닫기</button>
<button class="btn btn-apply" id="btnApply" onclick="applyAll()" disabled>적용하기 (0건)</button>
</div>
<script>
let itemData = [];
let isFirstRender = true;
window.onload = function() {
google.script.run
.withSuccessHandler(processData)
.withFailureHandler(function(err) {
document.getElementById('loaderOverlay').innerHTML =
`<div class="error-msg">❌ 초기화 오류<br>${err}<br>선택된 셀이 있는지 확인해주세요.</div>`;
})
.getSelectedDataForSpellCheck();
};
function processData(rows) {
if (rows.length === 0) {
document.getElementById('loaderOverlay').innerHTML = '<div class="error-msg">선택된 셀이 없거나 빈 셀입니다.</div>';
return;
}
document.getElementById('loadingText').innerText = `총 ${rows.length}개 항목 분석 중...`;
const container = document.getElementById('cardsContainer');
container.innerHTML = "";
let processedCount = 0;
const total = rows.length;
rows.forEach((row, index) => {
const div = document.createElement('div');
div.id = `item-${index}`;
div.className = 'check-item';
div.innerHTML = `
<div class="item-header"><span>행 ${row.row} (대기 중...)</span></div>
<div class="split-view"><div class="pane pane-left">...</div><div class="pane pane-right">...</div></div>
`;
container.appendChild(div);
google.script.run
.withSuccessHandler((res) => {
renderResult(index, row, res);
processedCount++;
updateApplyButton(processedCount, total);
})
.withFailureHandler((err) => {
renderError(index, row, err);
processedCount++;
updateApplyButton(processedCount, total);
})
.getBareunCorrection(row.original);
});
}
function renderError(index, rowData, errorMsg) {
if (isFirstRender) {
document.getElementById('loaderOverlay').style.display = 'none';
isFirstRender = false;
}
const itemDiv = document.getElementById(`item-${index}`);
if (!itemDiv) return;
itemDiv.innerHTML = `<div style="padding:20px; color:red;">❌ 분석 실패: ${errorMsg}</div>`;
}
function renderResult(index, rowData, apiResult) {
if (isFirstRender) {
document.getElementById('loaderOverlay').style.display = 'none';
isFirstRender = false;
}
const itemDiv = document.getElementById(`item-${index}`);
if (!itemDiv) return;
if (apiResult.error) {
renderError(index, rowData, apiResult.error);
return;
}
// [핵심] 라이브러리 사용 Diff
let diff;
try {
diff = diffWordsLib(rowData.original, apiResult.revised);
} catch(e) {
console.error(e);
diff = { left: rowData.original, right: apiResult.revised }; // 실패 시 원문 표시
}
let helpListHtml = "";
const analysisList = [];
if (apiResult.rawBlocks && apiResult.rawBlocks.length > 0) {
traverseBlocks(apiResult.rawBlocks, apiResult.helps, analysisList);
}
if (analysisList.length > 0) {
const listItems = analysisList.map(item => `
<li class="analysis-row">
<span class="err-word">${item.origin}</span>
<span class="arrow">→</span>
<span class="fix-word">${item.revised}</span>
<span class="fix-reason">${item.msg}</span>
</li>
`).join("");
helpListHtml = `
<div class="analysis-box">
<div class="analysis-title"><span class="material-icons" style="font-size:14px;">info</span> 상세 수정 내역 (${analysisList.length}건)</div>
<ul class="analysis-list">${listItems}</ul>
</div>
`;
}
itemDiv.innerHTML = `
<div class="item-header">
<span>행 ${rowData.row}</span>
<span style="color:#1a73e8;">분석 완료</span>
</div>
<div class="split-view">
<div class="pane pane-left">${diff.left}</div>
<div class="pane pane-right" contenteditable="true" spellcheck="false" id="edit-${index}" oninput="updateBytes(${index})">${diff.right}</div>
</div>
${helpListHtml}
<div class="byte-info">
<div class="byte-box" style="border-right:1px solid #eee;">원본: ${getByteLength(rowData.original)} byte</div>
<div class="byte-box">수정본: <span id="byte-${index}" class="byte-val">${getByteLength(apiResult.revised)}</span> byte</div>
</div>
`;
itemData[index] = { sheetName: rowData.sheetName, row: rowData.row, col: rowData.col };
}
function traverseBlocks(blocks, helps, resultsArray) {
if (!blocks) return;
blocks.forEach(function(block) {
if (block.nested && block.nested.length > 0) {
traverseBlocks(block.nested, helps, resultsArray);
} else {
const origin = block.origin.content || "";
const revised = block.revised || "";
if (origin === revised) return;
let msg = "교정";
if (block.revisions && block.revisions.length > 0) {
const helpId = block.revisions[0].helpId || block.revisions[0].help_id;
if (helps && !helps[helpId]) return;
if (helpId && helps && helps[helpId]) msg = helps[helpId].comment;
}
resultsArray.push({ origin: origin, revised: revised, msg: msg });
}
});
}
// [핵심] 라이브러리 활용 단어 단위 Diff (안정성 확보)
function diffWordsLib(text1, text2) {
// CDN 라이브러리가 로드되었는지 확인
if (typeof diff_match_patch === 'undefined') {
return { left: text1, right: text2 };
}
var dmp = new diff_match_patch();
// 1. 단어(공백 포함) 단위로 토큰화
// 정규식: 공백이나 문장부호 등을 기준으로 쪼개되, 분리자도 포함(capture group)
function tokenize(text) {
return text.split(/([^\S\r\n]+|[.,;!?])/g);
}
var t1 = tokenize(text1);
var t2 = tokenize(text2);
// 2. 토큰을 유니코드 문자로 1:1 매핑 (LineMode Diff 기법 응용)
var tokenMap = {};
var tokenArr = [];
var count = 0;
function encode(tokens) {
var chars = "";
for (var i = 0; i < tokens.length; i++) {
var t = tokens[i];
if (!tokenMap.hasOwnProperty(t)) {
tokenMap[t] = String.fromCharCode(count);
tokenArr[count] = t;
count++;
}
chars += tokenMap[t];
}
return chars;
}
var chars1 = encode(t1);
var chars2 = encode(t2);
// 3. Diff 실행 (글자가 아닌, 매핑된 코드로 비교)
var diffs = dmp.diff_main(chars1, chars2, false);
dmp.diff_cleanupSemantic(diffs);
// 4. HTML 생성
let leftHtml = "";
let rightHtml = "";
diffs.forEach(function(part) {
var op = part[0];
var chars = part[1];
var text = "";
// 유니코드를 다시 원래 단어로 복원
for (var i = 0; i < chars.length; i++) {
text += tokenArr[chars.charCodeAt(i)];
}
if (op === 0) {
leftHtml += text; rightHtml += text;
} else if (op === -1) {
leftHtml += `<span class="diff-del">${text}</span>`;
} else if (op === 1) {
rightHtml += `<span class="diff-add">${text}</span>`;
}
});
return { left: leftHtml, right: rightHtml };
}
function getByteLength(s) {
let b = 0; if (!s) return 0;
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c === 10) b += 2; else if (c >> 11) b += 3; else if (c >> 7) b += 2; else b += 1;
}
return b;
}
function updateBytes(index) {
const text = document.getElementById(`edit-${index}`).innerText;
document.getElementById(`byte-${index}`).innerText = getByteLength(text);
}
function updateApplyButton(current, total) {
const btn = document.getElementById('btnApply');
btn.innerText = `적용하기 (${current} / ${total})`;
if (current === total) btn.disabled = false;
}
function applyAll() {
if (!confirm("검사된 내용으로 원본 데이터를 덮어쓰시겠습니까?\n(되돌릴 수 없습니다)")) return;
const updates = [];
itemData.forEach((item, index) => {
if (item) {
const el = document.getElementById(`edit-${index}`);
if (el) updates.push({ sheetName: item.sheetName, row: item.row, col: item.col, text: el.innerText });
}
});
const btn = document.getElementById('btnApply');
btn.disabled = true; btn.innerText = "저장 중...";
google.script.run.withSuccessHandler((count) => {
alert(`✅ 총 ${count}건이 수정되었습니다.`);
google.script.host.close();
}).applySpellCheckResults(updates);
}
</script>
</body>
</html>
<? } else if (activeView === 'banned') { ?>
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<style>
body { padding: 20px; font-family: 'Noto Sans KR', sans-serif; background: #f8f9fa; margin: 0; height: 100vh; display: flex; flex-direction: column; box-sizing: border-box; }
.header-area { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.title { font-size: 20px; font-weight: 700; color: #d93025; display: flex; align-items: center; gap: 8px; }
.content-area { flex: 1; overflow-y: auto; padding-right: 10px; position: relative; }
.check-item { background: white; border: 1px solid #dadce0; border-radius: 8px; margin-bottom: 25px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden; }
.item-header { background: #f1f3f4; padding: 10px 15px; font-size: 14px; font-weight: 700; color: #3c4043; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }
.split-view { display: flex; min-height: 120px; }
.pane {
flex: 1; padding: 15px; font-size: 14px; line-height: 1.8; color: #3c4043;
white-space: pre-wrap; word-break: keep-all; text-align: justify;
}
.pane-left { border-right: 1px solid #eee; background: #fff; }
.pane-right { background: #f8fbff; outline: none; }
.pane-right:focus { background: #fff; box-shadow: inset 0 0 0 2px #1a73e8; }
/* 하이라이트 스타일 */
.diff-del { background-color: #ffe6e6; color: #c5221f; border-bottom: 2px solid #c5221f; text-decoration: none; padding: 1px 0; font-weight: 600; }
.diff-add { background-color: #e6f4ea; color: #188038; border-bottom: 2px solid #188038; text-decoration: none; padding: 1px 0; font-weight: 600; }
/* [신규] 상세 설명 박스 스타일 */
.analysis-box { border-top: 1px solid #eee; background: #fafafa; padding: 12px 15px; font-size: 13px; color: #444; }
.analysis-list { list-style: none; padding: 0; margin: 0; }
.analysis-row { padding: 4px 0; display: flex; align-items: flex-start; gap: 6px; }
.analysis-row:before { content: "•"; color: #d93025; font-weight: bold; margin-top: 1px; }
.err-word { color: #d93025; font-weight: bold; }
.fix-word { color: #188038; font-weight: bold; }
.footer-bar { margin-top: 15px; padding-top: 15px; border-top: 1px solid #dadce0; display: flex; justify-content: flex-end; gap: 10px; }
.btn { padding: 10px 20px; border-radius: 4px; border: none; font-weight: bold; cursor: pointer; font-size: 14px; }
.btn-cancel { background: #f1f3f4; color: #3c4043; }
.btn-apply { background: #1a73e8; color: white; box-shadow: 0 2px 5px rgba(26,115,232,0.3); }
.btn-apply:hover { background: #1765cc; }
.btn:disabled { background: #dadce0; cursor: not-allowed; box-shadow: none; color: #fff; }
.loader-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: white; z-index: 100; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #5f6368; font-size: 14px; }
.spinner { border: 4px solid #f3f3f3; border-top: 4px solid #1a73e8; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin-bottom: 15px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="header-area">
<div class="title"><span class="material-icons">block</span> 기재 금지어 검사</div>
<div id="checkLegend" style="font-size:12px; color:#5f6368; display:flex; gap:15px;">
<div><span style="background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;">금지어 발견</span></div>
<div>오른쪽 수정안에서 문맥에 맞게 직접 고쳐 주세요.</div>
</div>
</div>
<div id="checkView" class="content-area">
<div id="loaderOverlay" class="loader-overlay">
<div class="spinner"></div>
<div id="loadingText">데이터를 분석 중입니다...</div>
</div>
<div id="cardsContainer"></div>
</div>
<div class="footer-bar" id="footerCheck">
<button class="btn btn-cancel" onclick="google.script.host.close()">닫기</button>
<button class="btn btn-apply" id="btnApply" onclick="applyAll()" disabled>적용하기 (0건)</button>
</div>
<script>
let itemData = [];
let renderQueue = [];
const CHUNK_SIZE = 20;
window.onload = function() {
runCheck();
};
function runCheck() {
document.getElementById('loaderOverlay').style.display = 'flex';
document.getElementById('loadingText').innerText = "데이터를 분석 중입니다...";
document.getElementById('cardsContainer').innerHTML = "";
google.script.run
.withSuccessHandler(startRenderSequence)
.withFailureHandler(err => alert("오류: " + err))
.runBannedWordCheck();
}
function startRenderSequence(rows) {
document.getElementById('loaderOverlay').style.display = 'none';
const container = document.getElementById('cardsContainer');
if (!rows || rows.length === 0) {
container.innerHTML = '<div style="text-align:center; padding:50px; color:#5f6368;">금지어가 발견되지 않았습니다. 🎉</div>';
document.getElementById('btnApply').disabled = true;
return;
}
itemData = rows;
renderQueue = [...rows];
processRenderQueue();
document.getElementById('btnApply').innerText = `적용하기 (${rows.length}건)`;
document.getElementById('btnApply').disabled = false;
}
function processRenderQueue() {
if (renderQueue.length === 0) return;
const container = document.getElementById('cardsContainer');
const chunk = renderQueue.splice(0, CHUNK_SIZE);
chunk.forEach((row) => {
const originalIndex = itemData.findIndex(r => r.row === row.row && r.col === row.col);
let logHtml = "";
if (row.logs && row.logs.length > 0) {
const listItems = row.logs.map(log => {
const count = log.count > 1 ? ` (${log.count}회)` : '';
return `<li class="analysis-row"><span><span class="err-word">${escapeHtml(log.k)}</span>${count} 기재금지어가 발견되었습니다. 오른쪽 수정안에서 문맥에 맞게 직접 고쳐 주세요.</span></li>`;
}).join("");
logHtml = `
<div class="analysis-box">
<ul class="analysis-list">${listItems}</ul>
</div>
`;
}
const div = document.createElement('div');
div.className = 'check-item';
div.innerHTML = `
<div class="item-header">
<span>행 ${row.row}</span>
<span style="color:#d93025; font-weight:bold;">금지어 발견</span>
</div>
<div class="split-view">
<div class="pane pane-left">${highlightBannedWords(row.original, row.logs)}</div>
<div class="pane pane-right" contenteditable="true" spellcheck="false" id="edit-${originalIndex}"></div>
</div>
${logHtml}
`;
container.appendChild(div);
// 원문을 그대로 넣습니다. 기재금지어 검사는 자동 대체·삭제를 전혀 하지 않습니다.
document.getElementById(`edit-${originalIndex}`).textContent = row.original;
});
if (renderQueue.length > 0) {
requestAnimationFrame(processRenderQueue);
}
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function highlightBannedWords(text, logs) {
const keywords = (logs || []).map(log => log.k).filter(Boolean);
if (keywords.length === 0) return escapeHtml(text);
const fragments = keywords
.sort((a, b) => compactBannedWord(b).length - compactBannedWord(a).length)
.map(buildWhitespaceInsensitivePattern);
const pattern = new RegExp(`(${fragments.join('|')})`, 'gi');
return String(text).split(pattern).map((part, index) => {
const safeText = escapeHtml(part);
return index % 2 === 1 ? `<span class="diff-del">${safeText}</span>` : safeText;
}).join('');
}
function compactBannedWord(value) {
return String(value || '').replace(/\s+/g, '').toLocaleLowerCase();
}
function buildWhitespaceInsensitivePattern(keyword) {
return compactBannedWord(keyword)
.split('')
.map(escapeRegExp)
.join('[\\s\\u00A0]*');
}
function applyAll() {
if (!confirm("수정된 내용으로 원본을 덮어쓰시겠습니까?")) return;
const updates = [];
itemData.forEach((item, index) => {
const el = document.getElementById(`edit-${index}`);
if (el) updates.push({ sheetName: item.sheetName, row: item.row, col: item.col, text: el.innerText });
});
google.script.run.withSuccessHandler((c) => {
alert(`${c}건 수정 완료!`);
google.script.host.close();
}).applySpellCheckResults(updates);
}
</script>
</body>
</html>
<? } ?>