민기.한국

2026년 인공지능 활용 선도교사 연수 (충북 청주 - 7. 26.)

강사
교사 김민기(민기쌤)
일시
2026-07-26
장소
청주 오스코

(1) 클로바노트 음성 전사

관련 링크

(2) 유튜브 댓글 수집

아래 블로그의 설명에 따라 Youtube Data API 키를 발급합니다. 어려운 경우 최후의 수단으로 다음 연수용 API KEY를 사용할 수 있습니다. 이 KEY는 오늘 이후 폐기 처리되므로 유튜브 댓글 수집기가 더이상 작동하지 않습니다. AIzaSyDETqF5gzbFh3DG4U0N-rjT1yK2asM-1X4

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, '&amp;')
          .replace(/</g, '&lt;')
          .replace(/>/g, '&gt;')
          .replace(/"/g, '&quot;')
          .replace(/'/g, '&#039;');
      }
    </script>
  </body>
</html>

관련 링크

(3) 바른 API KEY 발급

관련 링크

(4) 민기쌤 생기부 도우미

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_ = ['유형', '기재 금지어', '대체 표현'];

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 {
          k: String(item && item.k || '').trim(),
          v: String(item && item.v || '').trim(),
          t: item && item.t === 'ban' ? 'ban' : 'replace'
        };
      })
      .filter(function(item) { return item.k !== ''; });
  } 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(item) {
    return [item.t === 'ban' ? '불가' : '대체', item.k, item.t === 'ban' ? '' : item.v];
  });
  if (rows.length > 0) sheet.getRange(2, 1, rows.length, 3).setValues(rows);

  sheet.setFrozenRows(1);
  sheet.setColumnWidth(1, 90);
  sheet.setColumnWidth(2, 180);
  sheet.setColumnWidth(3, 260);
  sheet.setTabColor('#d93025');

  if (sheet.getMaxRows() > 1) {
    var typeRule = SpreadsheetApp.newDataValidation()
      .requireValueInList(['대체', '불가'], true)
      .setAllowInvalid(false)
      .setHelpText('대체 또는 불가 중 하나를 선택하세요.')
      .build();
    sheet.getRange(2, 1, sheet.getMaxRows() - 1, 1).setDataValidation(typeRule);
  }
}

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_());
    }

    // 시트로 옮긴 뒤 예전 사용자별 저장값은 더 이상 별도 원본으로 남기지 않습니다.
    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 [
    { k: "MBTI", v: "성격 유형 검사", t: "replace" },
    { k: "커리어넷", v: "진로 정보 사이트", t: "replace" },
    { k: "메이저맵", v: "진로 정보망", t: "replace" },
    { k: "패들렛", v: "온라인 협업 도구", t: "replace" },
    { k: "띵커벨", v: "온라인 협업 플랫폼", t: "replace" },
    { k: "알로", v: "온라인 협업 플랫폼", t: "replace" },
    { k: "미리캔버스", v: "온라인 디자인 제작 플랫폼", t: "replace" },
    { k: "망고보드", v: "온라인 디자인 제작 플랫폼", t: "replace" },
    { k: "유튜버", v: "동영상 크리에이터", t: "replace" },
    { k: "유튜브", v: "동영상 공유 플랫폼", t: "replace" },
    { k: "넷플릭스", v: "온라인 동영상 서비스", t: "replace" },
    { k: "왓챠", v: "온라인 동영상 서비스", t: "replace" },
    { k: "티빙", v: "온라인 동영상 서비스", t: "replace" },
    { k: "네이버 폼", v: "온라인 설문조사", t: "replace" },
    { k: "구글 폼", v: "온라인 설문조사", t: "replace" },
    { k: "OECD", v: "경제 관련 국제 기구", t: "replace" },
    { k: "UN", v: "국제 기구", t: "replace" },
    { k: "유네스코", v: "교육 문화 국제 기구", t: "replace" },
    { k: "WTO", v: "무역 관련 국제 기구", t: "replace" },
    { k: "WHO", v: "보건 관련 국제 기구", t: "replace" },
    { k: "그린피스", v: "국제 환경 보전 단체", t: "replace" },
    { k: "앰네스티", v: "인권 관련 국제 비정부 기구", t: "replace" },
    { k: "IMF", v: "국제 통화 관련 기구", t: "replace" },
    { k: "인스타그램", v: "사회 관계망 서비스", t: "replace" },
    { k: "페이스북", v: "소셜 미디어 서비스", t: "replace" },
    { k: "카카오톡", v: "메신저 서비스", t: "replace" },
    { k: "네이버", v: "포털 사이트", t: "replace" },
    { k: "구글", v: "검색 엔진", t: "replace" },
    { k: "클래스 카드", v: "단어 학습 어플", t: "replace" },
    { k: "구글 클래스룸", v: "학습 플랫폼", t: "replace" },
    { k: "게더타운", v: "메타버스 플랫폼", t: "replace" },
    { k: "제페토", v: "메타버스 플랫폼", t: "replace" },
    { k: "아이패드", v: "태블릿 PC", t: "replace" },
    { k: "갤럭시 탭", v: "태블릿 PC", t: "replace" },
    { k: "비트코인", v: "가상 화폐", t: "replace" },
    { k: "TED", v: "온라인 강연", t: "replace" },
    { k: "반크", v: "사이버 외교 사절단", t: "replace" },
    { k: "TOEIC", v: "", t: "ban" },
    { k: "TOEFL", v: "", t: "ban" },
    { k: "TEPS", v: "", t: "ban" },
    { k: "대회", v: "", t: "ban" },
    { k: "수상", v: "", t: "ban" },
    { k: "한자능력검정", v: "", t: "ban" }
  ];
}

function getBannedWordList_() {
  var sheet = ensureBannedWordSheet_();
  var header = sheet.getRange(1, 1, 1, 3).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, 3).getDisplayValues();
  var seen = {};
  var list = [];

  values.forEach(function(row, index) {
    var rowNumber = index + 2;
    var type = String(row[0] || '').trim();
    var keyword = String(row[1] || '').trim();
    var replacement = String(row[2] || '').trim();

    if (!type && !keyword && !replacement) return;
    if (!keyword) throw new Error('기재금지어관리 시트 ' + rowNumber + '행의 기재 금지어(B열)를 입력해 주세요.');
    if (type !== '대체' && type !== '불가') {
      throw new Error('기재금지어관리 시트 ' + rowNumber + '행의 유형(A열)은 대체 또는 불가여야 합니다.');
    }
    if (type === '대체' && !replacement) {
      throw new Error('기재금지어관리 시트 ' + rowNumber + '행의 대체 표현(C열)을 입력하거나 유형을 불가로 바꿔 주세요.');
    }

    var duplicateKey = keyword.toLocaleLowerCase();
    if (seen[duplicateKey]) {
      throw new Error('기재금지어관리 시트 ' + rowNumber + '행의 기재 금지어가 ' + seen[duplicateKey] + '행과 중복됩니다: ' + keyword);
    }
    seen[duplicateKey] = rowNumber;
    list.push({ k: keyword, v: type === '불가' ? '' : replacement, t: type === '불가' ? 'ban' : 'replace' });
  });

  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_();
  db.sort((a, b) => b.k.length - a.k.length);

  var patterns = db.map(function(item) {
    return {
      regex: new RegExp(escapeRegExp(item.k), "gi"),
      v: item.v,
      t: item.t,
      k: item.k
    };
  });

  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 revised = original;
      var hasIssue = false;
      var logs = [];

      for (var p = 0; p < patterns.length; p++) {
        var item = patterns[p];
        if (item.regex.test(revised)) {
          hasIssue = true;
          var alreadyLogged = false;
          for(var l=0; l<logs.length; l++) { if(logs[l].k === item.k) { alreadyLogged = true; break; } }
          
          if (!alreadyLogged) {
            logs.push({ k: item.k, v: item.v, t: item.t });
          }

          if (item.t === "replace") {
            revised = revised.replace(item.regex, item.v);
          } else if (item.t === "ban") {
            revised = revised.replace(item.regex, ""); 
          }
        }
      }
      
      revised = revised.replace(/\s+/g, ' ').trim();

      if (hasIssue && original !== revised) {
        results.push({
          sheetName: sheetName,
          row: startRow + i,
          col: startCol + j,
          original: original,
          revised: revised,
          logs: logs
        });
      }
    }
  }
  return results;
}

function escapeRegExp(string) {
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 
}

// =================================================================
// [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><span style="background:#e6f4ea; color:#188038; border-bottom:2px solid #188038; padding:0 4px; font-weight:500;">대체어 제안</span></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);
          const diff = simpleDiff(row.original, row.revised);
          
          // [신규] 로그 설명 생성
          let logHtml = "";
          if (row.logs && row.logs.length > 0) {
            const listItems = row.logs.map(log => {
               let msg = "";
               if (log.t === 'replace') {
                 msg = `'<span class="err-word">${log.k}</span>'은(는) 사용할 수 없으므로 추천 대체어인 '<span class="fix-word">${log.v}</span>'(으)로 바꾸었습니다.`;
               } else {
                 msg = `'<span class="err-word">${log.k}</span>'은(는) 생기부 어디에도 기재 불가하므로 삭제해야 합니다.`;
               }
               return `<li class="analysis-row"><span>${msg}</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">${diff.left}</div>
              <div class="pane pane-right" contenteditable="true" id="edit-${originalIndex}">${diff.right}</div>
            </div>
            ${logHtml}
          `;
          container.appendChild(div);
        });

        if (renderQueue.length > 0) {
          requestAnimationFrame(processRenderQueue);
        }
      }

      // 자체 Diff (LCS)
      function simpleDiff(text1, text2) {
        const tokenize = (t) => t.split(/([^\S\r\n]+|[.,;!?"]+)/g);
        const t1 = tokenize(text1);
        const t2 = tokenize(text2);
        
        const dp = Array(t1.length + 1).fill(0).map(() => Array(t2.length + 1).fill(0));
        
        for (let i = 1; i <= t1.length; i++) {
          for (let j = 1; j <= t2.length; j++) {
            if (t1[i-1] === t2[j-1]) dp[i][j] = dp[i-1][j-1] + 1;
            else dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
          }
        }
        
        let i = t1.length, j = t2.length;
        let leftHtml = "", rightHtml = "";
        let parts = []; 
        
        while (i > 0 || j > 0) {
          if (i > 0 && j > 0 && t1[i-1] === t2[j-1]) {
            parts.unshift({ type: 'eq', val: t1[i-1] });
            i--; j--;
          } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {
            parts.unshift({ type: 'ins', val: t2[j-1] });
            j--;
          } else if (i > 0 && (j === 0 || dp[i][j-1] < dp[i-1][j])) {
            parts.unshift({ type: 'del', val: t1[i-1] });
            i--;
          }
        }
        
        parts.forEach(p => {
          if (p.type === 'eq') {
            leftHtml += p.val;
            rightHtml += p.val;
          } else if (p.type === 'del') {
            leftHtml += `<span class="diff-del">${p.val}</span>`;
          } else if (p.type === 'ins') {
            rightHtml += `<span class="diff-add">${p.val}</span>`;
          }
        });
        
        return { left: leftHtml, right: rightHtml };
      }

      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>

<? } ?>

(5) 설문조사, 지도안 작성

관련 링크