<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>교사 김민기(민기쌤) 연수 아카이브 | 민기.한국</title><link>https://민기.한국/</link><description>천안동중학교 교사 김민기(민기쌤)의 공식 연수 아카이브 민기.한국. 프로필과 AI 연수, 국어 수업 및 평가 자료를 제공합니다.</description><language>ko-KR</language><atom:link href="https://민기.한국/rss.xml" rel="self" type="application/rss+xml"/><lastBuildDate>Sat, 03 Oct 2026 15:00:00 GMT</lastBuildDate><item><title>충청북도교육청 AI 레벨업 (성찰) 연수</title><link>https://민기.한국/71</link><guid isPermaLink="true">https://민기.한국/71</guid><pubDate>Sun, 06 Sep 2026 05:15:01 GMT</pubDate><description><![CDATA[<h1>충청북도교육청 AI 레벨업 (성찰) 연수</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-11-06</dd><dt>장소</dt><dd>한국교원대학교</dd></dl><p>충청북도교육청 교사를 위한 AI 레벨업 - 성찰 단계 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F7182542_%5B%EC%A0%9C%EA%B3%B5%EC%9A%A9%5D_1~3%EC%B0%A8%EC%8B%9C-%EC%B5%9C%EC%A2%85%EC%88%98%EC%A0%95%EB%B3%B8.pdf?alt=media&amp;token=93431714-cc0d-463a-8786-9bf8315c5aa1">[제공용] 1~3차시-최종수정본.pdf</a></li></ul></section><section><h2>관련 링크</h2><ul><li><a href="https://짧은.한국/레벨업성찰실습파일">실습에 필요한 파일</a></li></ul></section>]]></description></item><item><title>충남온수업 미래교육배움터 제미나이, 챗GPT</title><link>https://민기.한국/74</link><guid isPermaLink="true">https://민기.한국/74</guid><pubDate>Thu, 17 Sep 2026 05:55:43 GMT</pubDate><description><![CDATA[<h1>충남온수업 미래교육배움터 제미나이, 챗GPT</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-09-17</dd><dt>장소</dt><dd>충청남도교육청 교육연수원</dd></dl><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F4034327_2026%EB%85%84%EB%8F%84_%EC%9D%BC%EB%B6%80%EA%B0%9C%EC%A0%95_%ED%95%99%EA%B5%90%ED%8F%AD%EB%A0%A5_%EC%82%AC%EC%95%88%EC%B2%98%EB%A6%AC_%EA%B0%80%EC%9D%B4%EB%93%9C%EB%B6%81(%EB%8B%A8%EB%A9%B4).pdf?alt=media&amp;token=9d59a203-1e0c-4a6c-b597-f254862649e9">2026년도 일부개정 학교폭력 사안처리 가이드북(단면).pdf</a></li></ul></section><section><h2>관련 링크</h2><ul><li><a href="https://chatgpt.com">ChatGPT</a></li><li><a href="https://gemini.google.com">Gemini</a></li><li><a href="https://notebook.google.com/">Gemini Notebook</a></li></ul></section><section><h2>ChatGPT로 이미지 만들기</h2><section><h3>웹캠으로 찍은 교사 사진을 이모티콘 세트로 만들기</h3><pre>첨부한 내 사진을 참고하여 교사가 수업 자료, 학급 안내, 온라인 게시판에서 사용할 수 있는 이모티콘 세트를 만들어 줘.

사진 속 사람의 얼굴 특징, 헤어스타일, 안경 여부, 전체적인 인상을 최대한 유지해 줘. 실제 사진을 그대로 복제한 느낌보다는 깔끔하고 친근한 2D 캐릭터 일러스트 스타일로 표현해 줘.

한 화면에 동일한 인물의 이모티콘 9개를 3×3 배열로 배치해 줘.

표정과 동작은 다음과 같이 구성해 줘.
1. 엄지손가락을 들며 잘했어요
2. 두 손을 모으며 감사합니다
3. 손가락으로 앞을 가리키며 여기를 보세요
4. 놀란 표정
5. 웃으며 박수치는 모습
6. 고민하는 표정
7. 힘내라는 의미로 주먹을 쥔 모습
8. 조용히 하라는 의미로 검지를 입 앞에 댄 모습
9. 손을 흔들며 인사하는 모습

교실에서 학생에게 보여 주기 적절한 표정으로 만들어 줘.
과도하게 귀엽거나 유아용 캐릭터처럼 표현하지 말고, 중고등학교 교사가 사용하기 좋은 단정한 느낌으로 만들어 줘.

각 이모티콘의 얼굴 비율, 옷, 헤어스타일, 그림체를 완전히 통일해 줘.
배경은 투명하게 처리해 줘.
말풍선과 글자는 넣지 마.
각 캐릭터 주변에는 충분한 여백을 두어 나중에 하나씩 잘라 사용할 수 있게 만들어 줘.</pre></section><section><h3>모의고사나 시험지용 흑백 삽화 만들기</h3><pre>지필평가 시험지에 사용할 교육용 삽화를 만들어 줘.

장면:
교실에서 교사 한 명과 중학생 한 명이 대화하고 있다.
교사는 학생을 바라보며 설명하고 있고, 학생은 교사의 말을 들은 뒤 대답한다.

표현 방식:
간결한 선화 스타일. 완전한 흑백.
회색 음영과 색채는 최소화하고 흰 배경을 유지할 것.
굵고 명확한 검은 선을 사용할 것.
가는 선이나 복잡한 질감은 피할 것.
복사기와 학교용 갱지에 인쇄했을 때도 인물과 사물이 명확히 구별되게 할 것.

구성:
등장인물은 필요한 사람만 배치할 것.
배경에는 책상과 칠판 정도만 최소한으로 표현할 것.
장식용 소품, 포스터, 창밖 풍경, 책 제목 등 문항 이해와 관계없는 요소는 넣지 말 것.
인물의 표정도 과장하지 말 것.
그림 자체가 정답에 대한 추가 단서를 제공해서는 안 된다.

중요:
그림 안에는 어떠한 한글, 영어, 숫자, 기호도 넣지 말 것.
말풍선은 비워 둘 것.
텍스트는 시험지 편집 과정에서 별도로 삽입할 예정이다.
이미지만 생성할 것.

가로형 삽화로 만들고, A4 시험지의 절반 폭 정도에 배치했을 때 선명하게 보이도록 단순하고 명확하게 구성해 줘.</pre></section><section><h3>학교 행사 포스터 만들기</h3><pre>학교 행사 포스터를 만들어 줘.

행사명:
함께 만드는 존중의 학교

대상:
중학생

목적:
학교폭력 예방과 존중하는 언어생활을 학생들에게 안내하는 교내 게시용 포스터.

전체 분위기:
중학생에게 유치해 보이지 않으면서 밝고 친근한 교육 포스터.
공공기관이나 교육청 캠페인 자료처럼 단정하고 신뢰감 있는 디자인.
지나치게 화려한 광고 디자인은 피할 것.

레이아웃:
맨 위에 행사명이 가장 크게 보이도록 구성.
중앙에 핵심 일러스트.
아래쪽에는 행사 날짜, 장소, 안내 내용.

A3 세로형 포스터 비율.
멀리서 보아도 중심 이미지가 명확하게 인식되게 만들어 줘.
인쇄했을 때 지나치게 어두워지지 않도록 흰색 계열의 여백을 충분히 확보해 줘.

행사에 대한 정보:
- 행사명: 「말 한마디가 학교를 바꿉니다」 존중 언어 실천 행사
- 학생들이 일상에서 사용하는 말을 돌아보고, 친구에게 존중과 배려가 담긴 표현을 직접 전달하는 학교폭력 예방 행사입니다.
- 운영은 점심시간과 등교시간을 활용해 1일 행사로 진행합니다. 학생에게 작은 카드 한 장을 나누어 주고, 친구에게 전하고 싶은 존중의 말을 적게 합니다. 예를 들면 “네가 있어서 우리 반이 더 편해”, “지난번에 도와줘서 고마워”, “내가 장난으로 했던 말이 불편했다면 미안해”처럼 구체적인 표현을 쓰도록 안내합니다.
- 작성한 카드는 친구에게 직접 전달하거나 중앙현관에 설치한 「존중의 말 게시판」에 붙입니다. 학생회가 참여해 좋은 표현 일부를 선정하고 점심시간 방송이나 학교 게시판을 통해 소개합니다.</pre></section></section>]]></description></item><item><title>충남교육청 AI 에듀테크 활용 역량강화 연수</title><link>https://민기.한국/73</link><guid isPermaLink="true">https://민기.한국/73</guid><pubDate>Sat, 12 Sep 2026 00:17:57 GMT</pubDate><description><![CDATA[<h1>충남교육청 AI 에듀테크 활용 역량강화 연수</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-09-12</dd><dt>장소</dt><dd>캠코인재개발원</dd></dl><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F3044914_%EB%B0%94%EC%9D%B4%EB%B8%8C_%EC%BD%94%EB%94%A9%EC%9C%BC%EB%A1%9C_%EC%88%98%EC%97%85%EC%97%90%EC%84%9C_100%25_%EC%93%B0%EB%8A%94_%EC%9B%B9_%EC%95%B1_%EB%A7%8C%EB%93%A4%EA%B8%B0_1%EC%B0%A8%EC%8B%9C(%EA%B9%80%EB%AF%BC%EA%B8%B0).pptx?alt=media&amp;token=a0d95c9d-f2c3-4069-bd27-c3fa1990a22d">바이브 코딩으로 수업에서 100% 쓰는 웹 앱 만들기_1차시(김민기).pptx</a></li><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F2976845_%EB%B0%94%EC%9D%B4%EB%B8%8C_%EC%BD%94%EB%94%A9%EC%9C%BC%EB%A1%9C_%EC%88%98%EC%97%85%EC%97%90%EC%84%9C_100%25_%EC%93%B0%EB%8A%94_%EC%9B%B9_%EC%95%B1_%EB%A7%8C%EB%93%A4%EA%B8%B0_2%EC%B0%A8%EC%8B%9C(%EC%9A%B0%EC%9D%B8%EC%88%99).pptx?alt=media&amp;token=3d83f1cd-d3d6-425f-b0c8-4dc092a37ed4">바이브 코딩으로 수업에서 100% 쓰는 웹 앱 만들기_2차시(우인숙).pptx</a></li></ul></section><section><h2>관련 링크</h2><ul><li><a href="https://www.survey.co.kr/?ACCESS_KEY=f0ea4615740c4784ad32&amp;grpid=&amp;UID=">사전 설문조사</a></li><li><a href="https://forms.gle/RYSWJCS1rgspj4Cd9">만족도 조사 1 (둘 다 응답 필수)</a></li><li><a href="https://www.survey.co.kr/?ACCESS_KEY=cb785ef52f5844b0ae52&amp;grpid=&amp;UID=">만족도 조사 2 (둘 다 응답 필수)</a></li><li><a href="https://bareun.ai/api-key">바른 AI API KEY 발급</a></li></ul></section><section><h2>생기부 점검</h2><section><h3>Code.gs</h3><pre>// =================================================================
// [0] 초기 설정 및 메뉴
// =================================================================

var MAX_STUDENT_ROWS_ = 40;

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu(&#39;민기쌤 생기부 도우미&#39;)
    .addItem(&#39;🚀 사이드바 열기&#39;, &#39;openSidebar&#39;)
    .addToUi();
}

function renderView_(viewName, viewData) {
  var template = HtmlService.createTemplateFromFile(&#39;Sidebar&#39;);

  template.initialView = viewName || &#39;sidebar&#39;;
  template.initialDataJson = JSON.stringify(viewData || {});

  return template.evaluate();
}

function openSidebar() {
  ensureBannedWordSheet_();

  var html = renderView_(&#39;sidebar&#39;)
    .setTitle(&#39;민기쌤 생기부 맞춤법 도우미&#39;)
    .setWidth(300);

  SpreadsheetApp.getUi().showSidebar(html);
}

function openSpellCheckViewer() {
  assertSelectionWithinLimit_();

  var html = renderView_(&#39;spellcheck&#39;)
    .setWidth(1000)
    .setHeight(700);

  SpreadsheetApp.getUi().showModelessDialog(
    html,
    &#39;📝 Bareun 맞춤법 검사 및 수정&#39;
  );
}

function openFormatCheckViewer(options) {
  var normalizedOptions = normalizeFormatOptions_(options);

  if (!hasAnyFormatOption_(normalizedOptions)) {
    throw new Error(&#39;형식 검사 항목을 하나 이상 선택해 주세요.&#39;);
  }

  assertSelectionWithinLimit_();

  var html = renderView_(&#39;format&#39;, normalizedOptions)
    .setWidth(1000)
    .setHeight(700);

  SpreadsheetApp.getUi().showModelessDialog(
    html,
    &#39;🔎 생기부 형식 검사&#39;
  );
}


// =================================================================
// [1] 선택 영역 공통 처리 및 40명 제한
// =================================================================

function getSelectedTextCells_() {
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spreadsheet.getActiveSheet();
  var range = sheet.getActiveRange();

  if (!range) {
    throw new Error(&#39;선택된 셀이 없습니다.&#39;);
  }

  var values = range.getValues();

  var startRow = range.getRow();
  var startCol = range.getColumn();

  var rows = [];
  var nonEmptyRowIndexes = {};

  for (var i = 0; i &lt; values.length; i++) {
    for (var j = 0; j &lt; values[i].length; j++) {
      var value = values[i][j];

      if (value === null || value === undefined) {
        continue;
      }

      var text = String(value);

      if (text.trim() === &#39;&#39;) {
        continue;
      }

      // 내용이 있는 행 하나를 학생 1명으로 계산
      nonEmptyRowIndexes[i] = true;

      rows.push({
        sheetName: sheet.getName(),
        row: startRow + i,
        col: startCol + j,
        original: text
      });
    }
  }

  var studentCount = Object.keys(nonEmptyRowIndexes).length;

  if (studentCount &gt; MAX_STUDENT_ROWS_) {
    throw new Error(
      &#39;한 번에 최대 &#39; +
      MAX_STUDENT_ROWS_ +
      &#39;명까지만 검사할 수 있습니다. 현재 선택 영역에는 내용이 있는 행이 &#39; +
      studentCount +
      &#39;개 있습니다.&#39;
    );
  }

  return {
    rows: rows,
    studentCount: studentCount
  };
}

function assertSelectionWithinLimit_() {
  var selected = getSelectedTextCells_();

  if (selected.rows.length === 0) {
    throw new Error(&#39;선택 영역에 검사할 내용이 없습니다.&#39;);
  }

  return selected;
}


// =================================================================
// [2] Bareun API 키 및 UI 설정
// =================================================================

function getBareunUserProperties_() {
  ensureBannedWordSheet_();

  var props = PropertiesService.getUserProperties();

  var currentPolicy = &#39;BAREUN_ONLY_KEYS_V3&#39;;
  var previousPolicy = props.getProperty(&#39;API_KEY_POLICY_VERSION&#39;);

  if (previousPolicy !== currentPolicy) {
    var allowedProperties = {
      UI_STATE: true,
      API_KEY_POLICY_VERSION: true
    };

    if (
      previousPolicy === &#39;USER_SUPPLIED_KEYS_V1&#39; ||
      previousPolicy === &#39;BAREUN_ONLY_KEYS_V1&#39; ||
      previousPolicy === &#39;BAREUN_ONLY_KEYS_V2&#39;
    ) {
      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(&#39;UI_STATE&#39;) || &#39;{}&#39;
      );
    } catch (e) {
      previousUiState = {};
    }

    var currentUiState = {};

    [&#39;card-api&#39;, &#39;sub-api-settings&#39;, &#39;card-util&#39;].forEach(function(id) {
      if (previousUiState[id]) {
        currentUiState[id] = true;
      }
    });

    props.setProperty(
      &#39;UI_STATE&#39;,
      JSON.stringify(currentUiState)
    );

    props.setProperty(
      &#39;API_KEY_POLICY_VERSION&#39;,
      currentPolicy
    );
  }

  return props;
}

function saveUserBareunKey(bareunKey) {
  var props = getBareunUserProperties_();

  var cleanBareunKey = String(
    bareunKey || &#39;&#39;
  ).trim();

  if (!cleanBareunKey) {
    throw new Error(
      &#39;Bareun API 키를 입력해 주세요. 이 도우미는 API 키를 제공하지 않습니다.&#39;
    );
  }

  props.setProperty(
    &#39;USER_BAREUN_KEY&#39;,
    cleanBareunKey
  );

  return {
    hasBareun: true
  };
}

function checkUserBareunStatus() {
  var props = getBareunUserProperties_();

  return {
    hasBareun: !!props.getProperty(&#39;USER_BAREUN_KEY&#39;)
  };
}

function clearUserBareunKey() {
  var props = getBareunUserProperties_();

  props.deleteProperty(
    &#39;USER_BAREUN_KEY&#39;
  );

  return {
    hasBareun: false
  };
}

function getBareunKey() {
  var key = getBareunUserProperties_()
    .getProperty(&#39;USER_BAREUN_KEY&#39;);

  if (!key) {
    throw new Error(
      &#39;Bareun API 키가 없습니다. 사이드바 상단에서 등록해 주세요.&#39;
    );
  }

  return key;
}


// =================================================================
// [3] Bareun 맞춤법 검사
// =================================================================

function getSelectedDataForSpellCheck() {
  var selected = assertSelectionWithinLimit_();

  return selected.rows;
}

function getBareunCorrection(text) {
  var userKey = getBareunKey();

  return callBareunApi(
    text,
    userKey
  );
}

function callBareunApi(text, userKey) {
  var url =
    &#39;https://api.bareun.ai/bareun.RevisionService/CorrectError&#39;;

  var options = {
    method: &#39;post&#39;,
    contentType: &#39;application/json&#39;,

    headers: {
      &#39;api-key&#39;: userKey
    },

    muteHttpExceptions: true,

    payload: JSON.stringify({
      document: {
        content: text,
        language: &#39;ko-KR&#39;
      },

      encoding_type: &#39;UTF16&#39;
    })
  };

  var response = UrlFetchApp.fetch(
    url,
    options
  );

  var responseCode =
    response.getResponseCode();

  var responseText =
    response.getContentText();

  if (responseCode !== 200) {
    return {
      error:
        &#39;Bareun API 오류: &#39; +
        responseCode
    };
  }

  var result;

  try {
    result = JSON.parse(
      responseText
    );
  } catch (e) {
    return {
      error:
        &#39;Bareun API 응답을 해석하지 못했습니다.&#39;
    };
  }

  return {
    revised:
      result.revised || text,

    helps:
      result.helps || {},

    rawBlocks:
      result.revisedBlocks ||
      result.revised_blocks ||
      [],

    error: null
  };
}


// =================================================================
// [4] 검사 결과 시트 반영
// =================================================================

function applySpellCheckResults(updates) {
  if (!Array.isArray(updates)) {
    throw new Error(
      &#39;적용할 데이터 형식이 올바르지 않습니다.&#39;
    );
  }

  var spreadsheet =
    SpreadsheetApp.getActiveSpreadsheet();

  updates.forEach(function(item) {
    var sheet =
      spreadsheet.getSheetByName(
        item.sheetName
      );

    if (!sheet) {
      throw new Error(
        &#39;검사한 시트를 찾을 수 없습니다: &#39; +
        item.sheetName
      );
    }

    sheet
      .getRange(
        item.row,
        item.col
      )
      .setValue(
        item.text
      );
  });

  return updates.length;
}


// =================================================================
// [5] 기재 금지어 관리
// =================================================================

var BANNED_WORD_SHEET_NAME_ =
  &#39;기재금지어관리&#39;;

var BANNED_WORD_HEADERS_ = [
  &#39;번호&#39;,
  &#39;기재 금지어&#39;
];

var LEGACY_BANNED_WORD_HEADERS_ = [
  &#39;유형&#39;,
  &#39;기재 금지어&#39;,
  &#39;대체 표현&#39;
];

var ONE_COLUMN_BANNED_WORD_HEADERS_ = [
  &#39;기재 금지어&#39;
];

function getLegacyBannedWordList_() {
  var saved =
    PropertiesService
      .getUserProperties()
      .getProperty(
        &#39;BANNED_WORDS_DB&#39;
      );

  if (!saved) {
    return getDefaultBannedList_();
  }

  try {
    var parsed =
      JSON.parse(saved);

    if (!Array.isArray(parsed)) {
      return getDefaultBannedList_();
    }

    return parsed
      .map(function(item) {
        return String(
          item &amp;&amp; item.k || &#39;&#39;
        ).trim();
      })
      .filter(function(keyword) {
        return keyword !== &#39;&#39;;
      });

  } catch (e) {
    return getDefaultBannedList_();
  }
}

function initializeBannedWordSheet_(sheet, list) {
  sheet
    .getRange(
      1,
      1,
      1,
      BANNED_WORD_HEADERS_.length
    )
    .setValues([
      BANNED_WORD_HEADERS_
    ])
    .setBackground(&#39;#fce8e6&#39;)
    .setFontColor(&#39;#a50e0e&#39;)
    .setFontWeight(&#39;bold&#39;)
    .setHorizontalAlignment(&#39;center&#39;);

  var rows =
    list.map(
      function(keyword, index) {
        return [
          index + 1,
          keyword
        ];
      }
    );

  if (rows.length &gt; 0) {
    sheet
      .getRange(
        2,
        1,
        rows.length,
        2
      )
      .setValues(
        rows
      );
  }

  sheet.setFrozenRows(1);
  sheet.setColumnWidth(1, 60);
  sheet.setColumnWidth(2, 260);
  sheet.setTabColor(&#39;#d93025&#39;);
}

function migrateLegacyBannedWordSheet_(sheet) {
  var lastColumn =
    sheet.getLastColumn();

  var legacyColumn;
  var clearColumns;

  if (
    lastColumn &gt;= 3 &amp;&amp;
    sheet
      .getRange(
        1,
        1,
        1,
        3
      )
      .getDisplayValues()[0]
      .join(&#39;|&#39;) ===
      LEGACY_BANNED_WORD_HEADERS_.join(&#39;|&#39;)
  ) {
    legacyColumn = 2;
    clearColumns = 3;

  } else if (
    sheet
      .getRange(
        1,
        1,
        1,
        1
      )
      .getDisplayValues()[0][0] ===
      ONE_COLUMN_BANNED_WORD_HEADERS_[0]
  ) {
    legacyColumn = 1;
    clearColumns = 1;

  } else {
    return false;
  }

  var lastRow =
    sheet.getLastRow();

  var keywords =
    lastRow &lt; 2
      ? []
      : sheet
          .getRange(
            2,
            legacyColumn,
            lastRow - 1,
            1
          )
          .getDisplayValues()
          .map(function(row) {
            return String(
              row[0] || &#39;&#39;
            ).trim();
          })
          .filter(function(keyword) {
            return keyword !== &#39;&#39;;
          });

  var clearRows =
    Math.max(
      sheet.getLastRow(),
      1
    );

  sheet
    .getRange(
      1,
      1,
      clearRows,
      clearColumns
    )
    .clearContent();

  initializeBannedWordSheet_(
    sheet,
    keywords
  );

  return true;
}

function ensureBannedWordSheet_() {
  var spreadsheet =
    SpreadsheetApp.getActiveSpreadsheet();

  if (!spreadsheet) {
    throw new Error(
      &#39;활성 스프레드시트를 찾을 수 없습니다.&#39;
    );
  }

  var originalSheet =
    spreadsheet.getActiveSheet();

  var originalRange =
    spreadsheet.getActiveRange();

  var lock =
    LockService.getDocumentLock() ||
    LockService.getScriptLock();

  lock.waitLock(30000);

  var sheet;

  try {
    sheet =
      spreadsheet.getSheetByName(
        BANNED_WORD_SHEET_NAME_
      );

    if (!sheet) {
      sheet =
        spreadsheet.insertSheet(
          BANNED_WORD_SHEET_NAME_
        );

      initializeBannedWordSheet_(
        sheet,
        getLegacyBannedWordList_()
      );

    } else {
      migrateLegacyBannedWordSheet_(
        sheet
      );
    }

    PropertiesService
      .getUserProperties()
      .deleteProperty(
        &#39;BANNED_WORDS_DB&#39;
      );

    return sheet;

  } finally {
    if (
      originalSheet &amp;&amp;
      sheet &amp;&amp;
      originalSheet.getSheetId() !==
        sheet.getSheetId()
    ) {
      spreadsheet.setActiveSheet(
        originalSheet
      );

      if (originalRange) {
        spreadsheet.setActiveRange(
          originalRange
        );
      }
    }

    lock.releaseLock();
  }
}

function openBannedWordManagementSheet() {
  var sheet =
    ensureBannedWordSheet_();

  sheet.activate();

  sheet
    .getRange(&#39;A1&#39;)
    .activate();

  return sheet.getName();
}

function getDefaultBannedList_() {
  return [
    &#39;MBTI&#39;,
    &#39;커리어넷&#39;,
    &#39;메이저맵&#39;,
    &#39;패들렛&#39;,
    &#39;띵커벨&#39;,
    &#39;알로&#39;,
    &#39;미리캔버스&#39;,
    &#39;망고보드&#39;,
    &#39;유튜버&#39;,
    &#39;유튜브&#39;,
    &#39;넷플릭스&#39;,
    &#39;왓챠&#39;,
    &#39;티빙&#39;,
    &#39;네이버 폼&#39;,
    &#39;구글 폼&#39;,
    &#39;OECD&#39;,
    &#39;UN&#39;,
    &#39;유네스코&#39;,
    &#39;WTO&#39;,
    &#39;WHO&#39;,
    &#39;그린피스&#39;,
    &#39;앰네스티&#39;,
    &#39;IMF&#39;,
    &#39;인스타그램&#39;,
    &#39;페이스북&#39;,
    &#39;카카오톡&#39;,
    &#39;네이버&#39;,
    &#39;구글&#39;,
    &#39;클래스 카드&#39;,
    &#39;구글 클래스룸&#39;,
    &#39;게더타운&#39;,
    &#39;제페토&#39;,
    &#39;아이패드&#39;,
    &#39;갤럭시 탭&#39;,
    &#39;비트코인&#39;,
    &#39;TED&#39;,
    &#39;반크&#39;,
    &#39;TOEIC&#39;,
    &#39;TOEFL&#39;,
    &#39;TEPS&#39;,
    &#39;대회&#39;,
    &#39;수상&#39;,
    &#39;한자능력검정&#39;
  ];
}

function getBannedWordList_() {
  var sheet =
    ensureBannedWordSheet_();

  var header =
    sheet
      .getRange(
        1,
        1,
        1,
        2
      )
      .getDisplayValues()[0];

  if (
    header.join(&#39;|&#39;) !==
    BANNED_WORD_HEADERS_.join(&#39;|&#39;)
  ) {
    throw new Error(
      &#39;기재금지어관리 시트의 1행은 &quot;번호 | 기재 금지어&quot; 순서여야 합니다.&#39;
    );
  }

  var lastRow =
    sheet.getLastRow();

  if (lastRow &lt; 2) {
    return [];
  }

  var values =
    sheet
      .getRange(
        2,
        1,
        lastRow - 1,
        2
      )
      .getDisplayValues();

  var seen = {};
  var list = [];

  values.forEach(
    function(row, index) {
      var rowNumber =
        index + 2;

      var number =
        String(
          row[0] || &#39;&#39;
        ).trim();

      var keyword =
        String(
          row[1] || &#39;&#39;
        ).trim();

      if (!number &amp;&amp; !keyword) {
        return;
      }

      if (!/^[1-9]\d*$/.test(number)) {
        throw new Error(
          &#39;기재금지어관리 시트 &#39; +
          rowNumber +
          &#39;행의 번호(A열)는 1 이상의 정수여야 합니다.&#39;
        );
      }

      if (!keyword) {
        throw new Error(
          &#39;기재금지어관리 시트 &#39; +
          rowNumber +
          &#39;행의 기재 금지어(B열)를 입력해 주세요.&#39;
        );
      }

      var duplicateKey =
        normalizeBannedWord_(
          keyword
        );

      if (seen[duplicateKey]) {
        throw new Error(
          &#39;기재금지어관리 시트 &#39; +
          rowNumber +
          &#39;행의 기재 금지어가 &#39; +
          seen[duplicateKey] +
          &#39;행과 중복됩니다: &#39; +
          keyword
        );
      }

      seen[duplicateKey] =
        rowNumber;

      list.push(
        keyword
      );
    }
  );

  return list;
}

function escapeRegExp_(value) {
  return String(value)
    .replace(
      /[.*+?^${}()|[\]\\]/g,
      &#39;\\$&amp;&#39;
    );
}

function normalizeBannedWord_(value) {
  return String(value || &#39;&#39;)
    .replace(/\s+/g, &#39;&#39;)
    .toLocaleLowerCase();
}

function buildWhitespaceInsensitivePattern_(keyword) {
  var compact =
    normalizeBannedWord_(
      keyword
    );

  if (!compact) {
    throw new Error(
      &#39;비어 있는 기재 금지어는 검사할 수 없습니다.&#39;
    );
  }

  return compact
    .split(&#39;&#39;)
    .map(function(character) {
      return escapeRegExp_(
        character
      );
    })
    .join(&#39;[\\s\\u00A0]*&#39;);
}

function createBannedMatcher_(list) {
  if (
    !list ||
    list.length === 0
  ) {
    return null;
  }

  var sorted =
    list
      .slice()
      .sort(
        function(a, b) {
          return (
            normalizeBannedWord_(b).length -
            normalizeBannedWord_(a).length
          );
        }
      );

  var canonical = {};

  sorted.forEach(
    function(keyword) {
      canonical[
        normalizeBannedWord_(keyword)
      ] = keyword;
    }
  );

  var pattern =
    new RegExp(
      &#39;(&#39; +
      sorted
        .map(
          buildWhitespaceInsensitivePattern_
        )
        .join(&#39;|&#39;) +
      &#39;)&#39;,
      &#39;gi&#39;
    );

  return {
    pattern: pattern,
    canonical: canonical
  };
}

function findBannedMatches_(text, matcher) {
  if (!matcher) {
    return {
      matches: [],
      counts: {}
    };
  }

  var matches = [];
  var counts = {};

  matcher.pattern.lastIndex = 0;

  var match;

  while (
    (match =
      matcher.pattern.exec(
        text
      )) !== null
  ) {
    var normalized =
      normalizeBannedWord_(
        match[0]
      );

    var keyword =
      matcher.canonical[
        normalized
      ];

    if (keyword) {
      matches.push({
        keyword: keyword,
        start: match.index,
        end:
          match.index +
          match[0].length
      });

      counts[keyword] =
        (counts[keyword] || 0) + 1;
    }

    // 혹시라도 길이 0인 정규식 일치가 생기는 경우 무한 반복 방지
    if (match[0].length === 0) {
      matcher.pattern.lastIndex++;
    }
  }

  matcher.pattern.lastIndex = 0;

  return {
    matches: matches,
    counts: counts
  };
}


// =================================================================
// [6] 형식 검사 옵션
// =================================================================

function normalizeFormatOptions_(options) {
  options = options || {};

  return {
    quotes: !!options.quotes,
    banned: !!options.banned,
    spaces: !!options.spaces,
    parentheses: !!options.parentheses
  };
}

function hasAnyFormatOption_(options) {
  return (
    options.quotes ||
    options.banned ||
    options.spaces ||
    options.parentheses
  );
}


// =================================================================
// [7] 소괄호 쌍 검사
// =================================================================

function analyzeParentheses_(text) {
  var stack = [];

  var unmatchedOpen = [];
  var unmatchedClose = [];

  for (
    var i = 0;
    i &lt; text.length;
    i++
  ) {
    var character =
      text.charAt(i);

    if (character === &#39;(&#39;) {
      stack.push(i);

    } else if (
      character === &#39;)&#39;
    ) {
      if (stack.length &gt; 0) {
        stack.pop();

      } else {
        unmatchedClose.push(i);
      }
    }
  }

  while (
    stack.length &gt; 0
  ) {
    unmatchedOpen.push(
      stack.pop()
    );
  }

  unmatchedOpen.sort(
    function(a, b) {
      return a - b;
    }
  );

  return {
    balanced:
      unmatchedOpen.length === 0 &amp;&amp;
      unmatchedClose.length === 0,

    unmatchedOpen:
      unmatchedOpen,

    unmatchedClose:
      unmatchedClose
  };
}


// =================================================================
// [8] 통합 형식 검사
// =================================================================

function runFormatCheck(options) {
  var normalizedOptions =
    normalizeFormatOptions_(
      options
    );

  if (
    !hasAnyFormatOption_(
      normalizedOptions
    )
  ) {
    throw new Error(
      &#39;형식 검사 항목을 하나 이상 선택해 주세요.&#39;
    );
  }

  var selected =
    assertSelectionWithinLimit_();

  var bannedMatcher = null;

  if (
    normalizedOptions.banned
  ) {
    bannedMatcher =
      createBannedMatcher_(
        getBannedWordList_()
      );
  }

  var results = [];

  selected.rows.forEach(
    function(item) {
      var original =
        item.original;

      // 형식 검사는 자동 수정하지 않음
      // 오른쪽 수정안도 항상 원문 그대로 시작
      var suggested =
        original;

      var issues = [];
      var ranges = [];

      // -------------------------------------------------------------
      // 1. 따옴표 통일 검사
      // -------------------------------------------------------------

      if (
        normalizedOptions.quotes
      ) {
        var quoteCount = 0;

        for (
          var q = 0;
          q &lt; original.length;
          q++
        ) {
          var quoteChar =
            original.charAt(q);

          if (
            quoteChar === &#39;‘&#39; ||
            quoteChar === &#39;’&#39; ||
            quoteChar === &#39;“&#39; ||
            quoteChar === &#39;”&#39;
          ) {
            quoteCount++;

            ranges.push({
              start: q,
              end: q + 1,
              type: &#39;quotes&#39;
            });
          }
        }

        if (quoteCount &gt; 0) {
          issues.push({
            type: &#39;quotes&#39;,
            title: &#39;따옴표 통일&#39;,
            message:
              &#39;둥근 따옴표 &#39; +
              quoteCount +
              &#39;개가 발견되었습니다. 하이라이팅된 부분을 확인한 뒤 수정안에서 직접 고쳐 주세요.&#39;
          });
        }
      }


      // -------------------------------------------------------------
      // 2. 스페이스바 실수 검사
      // 일반 공백이 2칸 이상 연속된 경우
      // -------------------------------------------------------------

      if (
        normalizedOptions.spaces
      ) {
        var spaceRegex =
          / {2,}/g;

        var spaceMatches = [];
        var spaceMatch;

        while (
          (spaceMatch =
            spaceRegex.exec(
              original
            )) !== null
        ) {
          spaceMatches.push({
            start:
              spaceMatch.index,

            end:
              spaceMatch.index +
              spaceMatch[0].length,

            length:
              spaceMatch[0].length
          });

          if (
            spaceMatch[0].length === 0
          ) {
            spaceRegex.lastIndex++;
          }
        }

        if (
          spaceMatches.length &gt; 0
        ) {
          spaceMatches.forEach(
            function(matchInfo) {
              ranges.push({
                start:
                  matchInfo.start,

                end:
                  matchInfo.end,

                type:
                  &#39;spaces&#39;
              });
            }
          );

          issues.push({
            type: &#39;spaces&#39;,
            title: &#39;스페이스바 실수&#39;,
            message:
              &#39;두 칸 이상 연속된 공백이 &#39; +
              spaceMatches.length +
              &#39;곳 발견되었습니다. 하이라이팅된 부분을 확인한 뒤 수정안에서 직접 고쳐 주세요.&#39;
          });
        }
      }


      // -------------------------------------------------------------
      // 3. 기재 금지어 검사
      // -------------------------------------------------------------

      if (
        normalizedOptions.banned &amp;&amp;
        bannedMatcher
      ) {
        var bannedResult =
          findBannedMatches_(
            original,
            bannedMatcher
          );

        bannedResult.matches.forEach(
          function(matchInfo) {
            ranges.push({
              start:
                matchInfo.start,

              end:
                matchInfo.end,

              type:
                &#39;banned&#39;
            });
          }
        );

        Object.keys(
          bannedResult.counts
        ).forEach(
          function(keyword) {
            var count =
              bannedResult.counts[
                keyword
              ];

            issues.push({
              type:
                &#39;banned&#39;,

              title:
                &#39;기재 금지어&#39;,

              message:
                &#39;&quot;&#39; +
                keyword +
                &#39;&quot;이(가) &#39; +
                count +
                &#39;회 발견되었습니다. 하이라이팅된 부분을 확인한 뒤 수정안에서 문맥에 맞게 직접 고쳐 주세요.&#39;
            });
          }
        );
      }


      // -------------------------------------------------------------
      // 4. 소괄호 쌍 검사
      // 개수뿐 아니라 등장 순서까지 검사
      // -------------------------------------------------------------

      if (
        normalizedOptions.parentheses
      ) {
        var parenResult =
          analyzeParentheses_(
            original
          );

        if (
          !parenResult.balanced
        ) {
          parenResult.unmatchedOpen.forEach(
            function(position) {
              ranges.push({
                start:
                  position,

                end:
                  position + 1,

                type:
                  &#39;parentheses&#39;
              });
            }
          );

          parenResult.unmatchedClose.forEach(
            function(position) {
              ranges.push({
                start:
                  position,

                end:
                  position + 1,

                type:
                  &#39;parentheses&#39;
              });
            }
          );

          var parenthesisMessages = [];

          if (
            parenResult.unmatchedOpen.length &gt; 0
          ) {
            parenthesisMessages.push(
              &#39;짝이 없는 여는 괄호 ( &#39; +
              parenResult.unmatchedOpen.length +
              &#39;개&#39;
            );
          }

          if (
            parenResult.unmatchedClose.length &gt; 0
          ) {
            parenthesisMessages.push(
              &#39;짝이 없는 닫는 괄호 ) &#39; +
              parenResult.unmatchedClose.length +
              &#39;개&#39;
            );
          }

          issues.push({
            type:
              &#39;parentheses&#39;,

            title:
              &#39;괄호 쌍 검사&#39;,

            message:
              parenthesisMessages.join(
                &#39;, &#39;
              ) +
              &#39;가 발견되었습니다. 빨간색으로 표시된 괄호를 확인한 뒤 수정안에서 직접 고쳐 주세요.&#39;
          });
        }
      }


      // 문제 있는 셀만 결과 창에 표시
      if (
        issues.length &gt; 0
      ) {
        results.push({
          sheetName:
            item.sheetName,

          row:
            item.row,

          col:
            item.col,

          original:
            original,

          // 자동 수정 없이 원문 그대로
          suggested:
            suggested,

          issues:
            issues,

          ranges:
            ranges
        });
      }
    }
  );

  return {
    studentCount:
      selected.studentCount,

    checkedCellCount:
      selected.rows.length,

    issueCellCount:
      results.length,

    rows:
      results,

    options:
      normalizedOptions
  };
}


// =================================================================
// [9] 호환용 기재 금지어 검사 함수
// 기존 코드에서 이 함수를 직접 호출하는 경우를 대비
// =================================================================

function runBannedWordCheck() {
  var result =
    runFormatCheck({
      quotes: false,
      banned: true,
      spaces: false,
      parentheses: false
    });

  return result.rows;
}


// =================================================================
// [10] UI 상태 저장 및 불러오기
// =================================================================

function saveUiState(
  cardId,
  isExpanded
) {
  var props =
    PropertiesService
      .getUserProperties();

  var state = {};

  try {
    state =
      JSON.parse(
        props.getProperty(
          &#39;UI_STATE&#39;
        ) || &#39;{}&#39;
      );

  } catch (e) {
    state = {};
  }

  state[cardId] =
    isExpanded;

  props.setProperty(
    &#39;UI_STATE&#39;,
    JSON.stringify(
      state
    )
  );
}

function getUiState() {
  var props =
    PropertiesService
      .getUserProperties();

  try {
    return JSON.parse(
      props.getProperty(
        &#39;UI_STATE&#39;
      ) || &#39;{}&#39;
    );

  } catch (e) {
    return {};
  }
}</pre></section><section><h3>Sidebar.html</h3><pre>&lt;? var allowedViews = [&quot;sidebar&quot;, &quot;spellcheck&quot;, &quot;format&quot;]; ?&gt;
&lt;? var activeView = allowedViews.indexOf(initialView) &gt;= 0 ? initialView : &quot;sidebar&quot;; ?&gt;


&lt;? if (activeView === &#39;sidebar&#39;) { ?&gt;

&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
  &lt;base target=&quot;_top&quot;&gt;

  &lt;link
    href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot;
    rel=&quot;stylesheet&quot;
  &gt;

  &lt;link
    href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot;
    rel=&quot;stylesheet&quot;
  &gt;

  &lt;style&gt;
    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      padding: 10px 15px 10px 2px;
      background: #f1f3f4;
      color: #202124;
      font-family: &#39;Noto Sans KR&#39;, 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=&quot;password&quot;] {
      width: 100%;
      padding: 10px;
      border: 2px solid #dadce0;
      border-radius: 6px;
      outline: none;
      font-family: inherit;
      font-size: 14px;
    }

    input[type=&quot;password&quot;]:focus {
      border-color: #1a73e8;
    }

    button {
      width: 100%;
      margin-top: 8px;
      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-warning {
      background: #fbbc04;
      color: #202124;
    }

    .btn-primary {
      background: #1a73e8;
      color: #fff;
    }

    .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;
    }

    .format-options {
      margin-top: 12px;
      padding: 12px 12px 9px;
      border-radius: 9px;
      background: #f8f9fa;
      border: 1px solid #e8eaed;
    }

    .format-options-title {
      margin-bottom: 9px;
      color: #3c4043;
      font-size: 12px;
      font-weight: 700;
    }

    .check-row {
      display: flex;
      align-items: center;
      gap: 8px;
      margin: 0 0 9px;
      color: #3c4043;
      font-size: 12px;
      cursor: pointer;
      line-height: 1.4;
    }

    .check-row input {
      width: 16px;
      height: 16px;
      margin: 0;
      accent-color: #1a73e8;
      flex-shrink: 0;
    }

    .option-note {
      margin-top: 9px;
      padding-top: 9px;
      border-top: 1px solid #e8eaed;
      color: #5f6368;
      font-size: 10px;
      line-height: 1.6;
    }

    .limit-note {
      margin-top: 11px;
      color: #5f6368;
      font-size: 11px;
      line-height: 1.5;
      text-align: center;
    }

    .text-action {
      display: block;
      margin-top: 13px;
      color: #5f6368;
      font-size: 11px;
      text-align: center;
      text-decoration: underline;
      cursor: pointer;
    }

    .text-action:hover {
      color: #1a73e8;
    }
  &lt;/style&gt;
&lt;/head&gt;


&lt;body&gt;

  &lt;div
    class=&quot;card&quot;
    id=&quot;card-api&quot;
  &gt;

    &lt;div
      class=&quot;card-header&quot;
      onclick=&quot;toggleCard(this)&quot;
    &gt;

      &lt;div class=&quot;header-title&quot;&gt;
        🔑 Bareun API 키 설정
      &lt;/div&gt;

      &lt;span class=&quot;material-icons toggle-icon&quot;&gt;
        expand_more
      &lt;/span&gt;

    &lt;/div&gt;


    &lt;div class=&quot;card-body&quot;&gt;

      &lt;div
        class=&quot;sub-section&quot;
        id=&quot;sub-api-settings&quot;
      &gt;

        &lt;div
          class=&quot;sub-header&quot;
          onclick=&quot;toggleSub(this)&quot;
        &gt;

          &lt;span&gt;
            내 Bareun API 키
          &lt;/span&gt;

          &lt;span class=&quot;material-icons sub-icon&quot;&gt;
            expand_more
          &lt;/span&gt;

        &lt;/div&gt;


        &lt;div class=&quot;sub-body&quot;&gt;

          &lt;div
            id=&quot;api-loading&quot;
            class=&quot;api-status&quot;
          &gt;
            ⌛ API 키 설정 확인 중...
          &lt;/div&gt;


          &lt;div
            id=&quot;api-settings&quot;
            class=&quot;hidden&quot;
          &gt;

            &lt;div
              id=&quot;apiStatusSummary&quot;
              class=&quot;api-status&quot;
            &gt;&lt;/div&gt;


            &lt;label
              class=&quot;input-label&quot;
              for=&quot;bareunKeyInput&quot;
            &gt;
              Bareun API 키
            &lt;/label&gt;


            &lt;input
              type=&quot;password&quot;
              id=&quot;bareunKeyInput&quot;
              autocomplete=&quot;off&quot;
              placeholder=&quot;본인이 발급받은 키를 입력하세요&quot;
            &gt;


            &lt;button
              class=&quot;btn-auth&quot;
              id=&quot;saveKeyBtn&quot;
              onclick=&quot;saveBareunKey()&quot;
            &gt;
              내 Bareun 키 저장
            &lt;/button&gt;


            &lt;button
              class=&quot;btn-secondary hidden&quot;
              id=&quot;clearKeyBtn&quot;
              onclick=&quot;resetBareunKey()&quot;
            &gt;
              저장된 Bareun 키 삭제
            &lt;/button&gt;

          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;


  &lt;div
    class=&quot;card expanded&quot;
    id=&quot;card-util&quot;
  &gt;

    &lt;div
      class=&quot;card-header&quot;
      onclick=&quot;toggleCard(this)&quot;
    &gt;

      &lt;div class=&quot;header-title&quot;&gt;
        📝 선택 영역 검사
      &lt;/div&gt;

      &lt;span class=&quot;material-icons toggle-icon&quot;&gt;
        expand_more
      &lt;/span&gt;

    &lt;/div&gt;


    &lt;div class=&quot;card-body&quot;&gt;

      &lt;button
        class=&quot;btn-warning&quot;
        id=&quot;checkBtn&quot;
        onclick=&quot;runSpellCheck()&quot;
      &gt;
        맞춤법 검사
      &lt;/button&gt;


      &lt;button
        class=&quot;btn-primary&quot;
        id=&quot;formatCheckBtn&quot;
        onclick=&quot;runFormatCheck()&quot;
      &gt;
        형식 검사
      &lt;/button&gt;


      &lt;div class=&quot;format-options&quot;&gt;

        &lt;div class=&quot;format-options-title&quot;&gt;
          형식 검사 항목
        &lt;/div&gt;


        &lt;label class=&quot;check-row&quot;&gt;
          &lt;input
            type=&quot;checkbox&quot;
            id=&quot;optionQuotes&quot;
            checked
          &gt;

          &lt;span&gt;
            따옴표 통일 (&#39; &quot;)
          &lt;/span&gt;
        &lt;/label&gt;


        &lt;label class=&quot;check-row&quot;&gt;
          &lt;input
            type=&quot;checkbox&quot;
            id=&quot;optionBanned&quot;
            checked
          &gt;

          &lt;span&gt;
            기재 금지어 검사
          &lt;/span&gt;
        &lt;/label&gt;


        &lt;label class=&quot;check-row&quot;&gt;
          &lt;input
            type=&quot;checkbox&quot;
            id=&quot;optionSpaces&quot;
            checked
          &gt;

          &lt;span&gt;
            스페이스바 실수 검사
          &lt;/span&gt;
        &lt;/label&gt;


        &lt;label class=&quot;check-row&quot;&gt;
          &lt;input
            type=&quot;checkbox&quot;
            id=&quot;optionParentheses&quot;
            checked
          &gt;

          &lt;span&gt;
            괄호 쌍 검사
          &lt;/span&gt;
        &lt;/label&gt;

      &lt;/div&gt;


      &lt;div
        id=&quot;checkStatus&quot;
        class=&quot;status-box&quot;
      &gt;&lt;/div&gt;


      &lt;div class=&quot;limit-note&quot;&gt;
        한 번에 최대 40명까지 검사할 수 있습니다.&lt;br&gt;
      &lt;/div&gt;


      &lt;span
        class=&quot;text-action&quot;
        onclick=&quot;openBannedWordManagement()&quot;
      &gt;
        기재금지어관리 시트 열기
      &lt;/span&gt;

    &lt;/div&gt;
  &lt;/div&gt;


  &lt;script&gt;
    let currentApiStatus = {
      hasBareun: false
    };


    function getErrorMessage(error) {
      if (!error) {
        return &#39;알 수 없는 오류가 발생했습니다.&#39;;
      }

      if (error.message) {
        return error.message;
      }

      return String(error)
        .replace(/^Error:\s*/, &#39;&#39;);
    }


    function updateApiState(status) {
      currentApiStatus = {
        hasBareun:
          !!(
            status &amp;&amp;
            status.hasBareun
          )
      };


      const summary =
        document.getElementById(
          &#39;apiStatusSummary&#39;
        );

      const clearButton =
        document.getElementById(
          &#39;clearKeyBtn&#39;
        );

      const spellCheckButton =
        document.getElementById(
          &#39;checkBtn&#39;
        );

      const apiCard =
        document.getElementById(
          &#39;card-api&#39;
        );

      const apiSub =
        document.getElementById(
          &#39;sub-api-settings&#39;
        );


      summary.innerHTML =
        currentApiStatus.hasBareun

          ? &#39;&lt;div class=&quot;ok&quot;&gt;✅ Bareun 키 저장됨&lt;/div&gt;&#39;

          : &#39;&lt;div class=&quot;missing&quot;&gt;❌ 맞춤법 검사를 사용하려면 Bareun 키가 필요합니다.&lt;/div&gt;&#39;;


      clearButton.classList.toggle(
        &#39;hidden&#39;,
        !currentApiStatus.hasBareun
      );


      spellCheckButton.disabled =
        !currentApiStatus.hasBareun;


      spellCheckButton.title =
        currentApiStatus.hasBareun
          ? &#39;&#39;
          : &#39;Bareun API 키를 먼저 저장해 주세요.&#39;;


      apiCard.classList.toggle(
        &#39;expanded&#39;,
        !currentApiStatus.hasBareun
      );


      apiSub.classList.toggle(
        &#39;expanded&#39;,
        !currentApiStatus.hasBareun
      );
    }


    function toggleCard(header) {
      const card =
        header.parentElement;

      const isExpanded =
        card.classList.toggle(
          &#39;expanded&#39;
        );

      google.script.run
        .saveUiState(
          card.id,
          isExpanded
        );
    }


    function toggleSub(header) {
      const section =
        header.parentElement;

      const isExpanded =
        section.classList.toggle(
          &#39;expanded&#39;
        );

      google.script.run
        .saveUiState(
          section.id,
          isExpanded
        );
    }


    function saveBareunKey() {
      const button =
        document.getElementById(
          &#39;saveKeyBtn&#39;
        );

      const keyInput =
        document.getElementById(
          &#39;bareunKeyInput&#39;
        );

      const key =
        keyInput.value.trim();


      if (!key) {
        alert(
          &#39;Bareun API 키를 입력해 주세요.&#39;
        );

        return;
      }


      const originalText =
        button.innerText;

      button.disabled = true;

      button.innerText =
        &#39;저장 중...&#39;;


      google.script.run

        .withSuccessHandler(
          function(status) {
            keyInput.value = &#39;&#39;;

            updateApiState(
              status
            );

            button.disabled =
              false;

            button.innerText =
              originalText;

            alert(
              &#39;✅ Bareun API 키를 사용자 설정에 저장했습니다.&#39;
            );
          }
        )

        .withFailureHandler(
          function(error) {
            button.disabled =
              false;

            button.innerText =
              originalText;

            alert(
              &#39;Bareun API 키 저장 오류: &#39; +
              getErrorMessage(error)
            );
          }
        )

        .saveUserBareunKey(
          key
        );
    }


    function resetBareunKey() {
      if (
        !confirm(
          &#39;저장된 Bareun API 키를 삭제하시겠습니까?&#39;
        )
      ) {
        return;
      }


      google.script.run

        .withSuccessHandler(
          function(status) {
            document
              .getElementById(
                &#39;bareunKeyInput&#39;
              )
              .value = &#39;&#39;;

            updateApiState(
              status
            );

            alert(
              &#39;저장된 Bareun API 키를 삭제했습니다.&#39;
            );
          }
        )

        .withFailureHandler(
          function(error) {
            alert(
              &#39;Bareun API 키 삭제 오류: &#39; +
              getErrorMessage(error)
            );
          }
        )

        .clearUserBareunKey();
    }


    function runSpellCheck() {
      if (
        !currentApiStatus.hasBareun
      ) {
        alert(
          &#39;Bareun API 키를 먼저 저장해 주세요.&#39;
        );

        return;
      }


      const statusBox =
        document.getElementById(
          &#39;checkStatus&#39;
        );

      statusBox.innerText =
        &#39;맞춤법 검사 창을 여는 중...&#39;;


      google.script.run

        .withSuccessHandler(
          function() {
            statusBox.innerText =
              &#39;&#39;;
          }
        )

        .withFailureHandler(
          function(error) {
            statusBox.innerText =
              &#39;&#39;;

            alert(
              getErrorMessage(error)
            );
          }
        )

        .openSpellCheckViewer();
    }


    function runFormatCheck() {
      const options = {
        quotes:
          document
            .getElementById(
              &#39;optionQuotes&#39;
            )
            .checked,

        banned:
          document
            .getElementById(
              &#39;optionBanned&#39;
            )
            .checked,

        spaces:
          document
            .getElementById(
              &#39;optionSpaces&#39;
            )
            .checked,

        parentheses:
          document
            .getElementById(
              &#39;optionParentheses&#39;
            )
            .checked
      };


      if (
        !options.quotes &amp;&amp;
        !options.banned &amp;&amp;
        !options.spaces &amp;&amp;
        !options.parentheses
      ) {
        alert(
          &#39;형식 검사 항목을 하나 이상 선택해 주세요.&#39;
        );

        return;
      }


      const statusBox =
        document.getElementById(
          &#39;checkStatus&#39;
        );

      statusBox.innerText =
        &#39;형식 검사 창을 여는 중...&#39;;


      google.script.run

        .withSuccessHandler(
          function() {
            statusBox.innerText =
              &#39;&#39;;
          }
        )

        .withFailureHandler(
          function(error) {
            statusBox.innerText =
              &#39;&#39;;

            alert(
              getErrorMessage(error)
            );
          }
        )

        .openFormatCheckViewer(
          options
        );
    }


    function openBannedWordManagement() {
      google.script.run

        .withFailureHandler(
          function(error) {
            alert(
              &#39;오류 발생: &#39; +
              getErrorMessage(error)
            );
          }
        )

        .openBannedWordManagementSheet();
    }


    window.onload = function() {
      google.script.run

        .withSuccessHandler(
          function(status) {
            document
              .getElementById(
                &#39;api-loading&#39;
              )
              .classList
              .add(
                &#39;hidden&#39;
              );


            document
              .getElementById(
                &#39;api-settings&#39;
              )
              .classList
              .remove(
                &#39;hidden&#39;
              );


            updateApiState(
              status
            );


            google.script.run

              .withSuccessHandler(
                function(states) {
                  Object
                    .keys(
                      states || {}
                    )
                    .forEach(
                      function(id) {
                        const element =
                          document.getElementById(
                            id
                          );

                        if (
                          id === &#39;card-api&#39; ||
                          id === &#39;sub-api-settings&#39;
                        ) {
                          return;
                        }

                        if (
                          element &amp;&amp;
                          states[id]
                        ) {
                          element.classList.add(
                            &#39;expanded&#39;
                          );
                        }
                      }
                    );


                  updateApiState(
                    status
                  );
                }
              )

              .getUiState();
          }
        )

        .withFailureHandler(
          function(error) {
            document
              .getElementById(
                &#39;api-loading&#39;
              )
              .classList
              .add(
                &#39;hidden&#39;
              );


            document
              .getElementById(
                &#39;api-settings&#39;
              )
              .classList
              .remove(
                &#39;hidden&#39;
              );


            updateApiState({
              hasBareun: false
            });


            document
              .getElementById(
                &#39;apiStatusSummary&#39;
              )
              .textContent =
                &#39;설정 확인 오류: &#39; +
                getErrorMessage(error);
          }
        )

        .checkUserBareunStatus();
    };
  &lt;/script&gt;

&lt;/body&gt;
&lt;/html&gt;


&lt;? } else if (activeView === &#39;spellcheck&#39;) { ?&gt;


&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
  &lt;base target=&quot;_top&quot;&gt;

  &lt;link
    href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot;
    rel=&quot;stylesheet&quot;
  &gt;

  &lt;link
    href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot;
    rel=&quot;stylesheet&quot;
  &gt;

  &lt;script
    src=&quot;https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch.js&quot;
  &gt;&lt;/script&gt;


  &lt;style&gt;
    body {
      padding: 20px;
      font-family: &#39;Noto Sans KR&#39;, 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,.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,.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;
    }
  &lt;/style&gt;
&lt;/head&gt;


&lt;body&gt;

  &lt;div class=&quot;header-area&quot;&gt;

    &lt;div class=&quot;title&quot;&gt;
      &lt;span class=&quot;material-icons&quot;&gt;
        fact_check
      &lt;/span&gt;

      맞춤법 검사 및 수정
    &lt;/div&gt;


    &lt;div
      style=&quot;
        font-size:12px;
        color:#5f6368;
        display:flex;
        gap:15px;
      &quot;
    &gt;

      &lt;div&gt;
        &lt;span
          style=&quot;
            background:#ffe6e6;
            color:#c5221f;
            border-bottom:2px solid #c5221f;
            padding:0 4px;
            font-weight:500;
          &quot;
        &gt;
          원본 오류
        &lt;/span&gt;
      &lt;/div&gt;

      &lt;div&gt;
        &lt;span
          style=&quot;
            background:#e6f4ea;
            color:#188038;
            border-bottom:2px solid #188038;
            padding:0 4px;
            font-weight:500;
          &quot;
        &gt;
          수정 제안
        &lt;/span&gt;
      &lt;/div&gt;

    &lt;/div&gt;
  &lt;/div&gt;


  &lt;div
    id=&quot;mainContent&quot;
    class=&quot;content-area&quot;
  &gt;

    &lt;div
      id=&quot;loaderOverlay&quot;
      class=&quot;loader-overlay&quot;
    &gt;

      &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;

      &lt;div id=&quot;loadingText&quot;&gt;
        데이터를 불러오는 중입니다...
      &lt;/div&gt;

    &lt;/div&gt;


    &lt;div id=&quot;cardsContainer&quot;&gt;&lt;/div&gt;

  &lt;/div&gt;


  &lt;div class=&quot;footer-bar&quot;&gt;

    &lt;button
      class=&quot;btn btn-cancel&quot;
      onclick=&quot;google.script.host.close()&quot;
    &gt;
      닫기
    &lt;/button&gt;


    &lt;button
      class=&quot;btn btn-apply&quot;
      id=&quot;btnApply&quot;
      onclick=&quot;applyAll()&quot;
      disabled
    &gt;
      적용하기 (0건)
    &lt;/button&gt;

  &lt;/div&gt;


  &lt;script&gt;
    let itemData = [];

    let spellRows = [];

    let processedCount = 0;

    let nextIndex = 0;

    let activeCount = 0;

    let isFirstRender = true;

    const MAX_CONCURRENT = 5;


    function escapeHtml(value) {
      return String(value)

        .replace(
          /&amp;/g,
          &#39;&amp;amp;&#39;
        )

        .replace(
          /&lt;/g,
          &#39;&amp;lt;&#39;
        )

        .replace(
          /&gt;/g,
          &#39;&amp;gt;&#39;
        )

        .replace(
          /&quot;/g,
          &#39;&amp;quot;&#39;
        )

        .replace(
          /&#39;/g,
          &#39;&amp;#39;&#39;
        );
    }


    function getErrorMessage(error) {
      if (!error) {
        return &#39;알 수 없는 오류가 발생했습니다.&#39;;
      }

      if (error.message) {
        return error.message;
      }

      return String(error)
        .replace(/^Error:\s*/, &#39;&#39;);
    }


    window.onload = function() {
      google.script.run

        .withSuccessHandler(
          processData
        )

        .withFailureHandler(
          function(error) {
            document
              .getElementById(
                &#39;loaderOverlay&#39;
              )
              .innerHTML =
                &#39;&lt;div class=&quot;error-msg&quot;&gt;&#39; +
                &#39;❌ 초기화 오류&lt;br&gt;&#39; +
                escapeHtml(
                  getErrorMessage(error)
                ) +
                &#39;&lt;br&gt;선택된 셀을 확인해 주세요.&#39; +
                &#39;&lt;/div&gt;&#39;;
          }
        )

        .getSelectedDataForSpellCheck();
    };


    function processData(rows) {
      if (
        !rows ||
        rows.length === 0
      ) {
        document
          .getElementById(
            &#39;loaderOverlay&#39;
          )
          .innerHTML =
            &#39;&lt;div class=&quot;error-msg&quot;&gt;&#39; +
            &#39;선택 영역에 검사할 내용이 없습니다.&#39; +
            &#39;&lt;/div&gt;&#39;;

        return;
      }


      spellRows = rows;

      processedCount = 0;

      nextIndex = 0;

      activeCount = 0;


      document
        .getElementById(
          &#39;loadingText&#39;
        )
        .innerText =
          &#39;총 &#39; +
          rows.length +
          &#39;개 항목 분석 중...&#39;;


      const container =
        document.getElementById(
          &#39;cardsContainer&#39;
        );

      container.innerHTML = &#39;&#39;;


      rows.forEach(
        function(row, index) {
          const div =
            document.createElement(
              &#39;div&#39;
            );

          div.id =
            &#39;item-&#39; + index;

          div.className =
            &#39;check-item&#39;;


          div.innerHTML =
            &#39;&lt;div class=&quot;item-header&quot;&gt;&#39; +
              &#39;&lt;span&gt;&#39; +
                row.row +
                &#39;행 / &#39; +
                row.col +
                &#39;열 (대기 중...)&#39; +
              &#39;&lt;/span&gt;&#39; +
            &#39;&lt;/div&gt;&#39; +

            &#39;&lt;div class=&quot;split-view&quot;&gt;&#39; +
              &#39;&lt;div class=&quot;pane pane-left&quot;&gt;...&lt;/div&gt;&#39; +
              &#39;&lt;div class=&quot;pane pane-right&quot;&gt;...&lt;/div&gt;&#39; +
            &#39;&lt;/div&gt;&#39;;


          container.appendChild(
            div
          );
        }
      );


      pumpQueue();
    }


    function pumpQueue() {
      while (
        activeCount &lt; MAX_CONCURRENT &amp;&amp;
        nextIndex &lt; spellRows.length
      ) {
        const index =
          nextIndex++;

        const row =
          spellRows[index];

        activeCount++;


        google.script.run

          .withSuccessHandler(
            function(result) {
              renderResult(
                index,
                row,
                result
              );

              activeCount--;

              processedCount++;

              updateApplyButton(
                processedCount,
                spellRows.length
              );

              pumpQueue();
            }
          )

          .withFailureHandler(
            function(error) {
              renderError(
                index,
                row,
                getErrorMessage(error)
              );

              activeCount--;

              processedCount++;

              updateApplyButton(
                processedCount,
                spellRows.length
              );

              pumpQueue();
            }
          )

          .getBareunCorrection(
            row.original
          );
      }
    }


    function hideLoaderOnce() {
      if (!isFirstRender) {
        return;
      }

      document
        .getElementById(
          &#39;loaderOverlay&#39;
        )
        .style
        .display = &#39;none&#39;;

      isFirstRender = false;
    }


    function renderError(
      index,
      rowData,
      errorMsg
    ) {
      hideLoaderOnce();


      const itemDiv =
        document.getElementById(
          &#39;item-&#39; + index
        );

      if (!itemDiv) {
        return;
      }


      itemDiv.innerHTML =
        &#39;&lt;div style=&quot;padding:20px; color:#d93025;&quot;&gt;&#39; +
          &#39;❌ 분석 실패: &#39; +
          escapeHtml(
            errorMsg
          ) +
        &#39;&lt;/div&gt;&#39;;
    }


    function renderResult(
      index,
      rowData,
      apiResult
    ) {
      hideLoaderOnce();


      const itemDiv =
        document.getElementById(
          &#39;item-&#39; + index
        );

      if (!itemDiv) {
        return;
      }


      if (
        !apiResult ||
        apiResult.error
      ) {
        renderError(
          index,
          rowData,
          apiResult
            ? apiResult.error
            : &#39;검사 결과가 없습니다.&#39;
        );

        return;
      }


      let diff;

      try {
        diff =
          diffWordsLib(
            rowData.original,
            apiResult.revised
          );

      } catch (error) {
        diff = {
          left:
            escapeHtml(
              rowData.original
            ),

          right:
            escapeHtml(
              apiResult.revised
            )
        };
      }


      let helpListHtml = &#39;&#39;;

      const analysisList = [];


      if (
        apiResult.rawBlocks &amp;&amp;
        apiResult.rawBlocks.length &gt; 0
      ) {
        traverseBlocks(
          apiResult.rawBlocks,
          apiResult.helps,
          analysisList
        );
      }


      if (
        analysisList.length &gt; 0
      ) {
        const listItems =
          analysisList
            .map(
              function(item) {
                return (
                  &#39;&lt;li class=&quot;analysis-row&quot;&gt;&#39; +

                    &#39;&lt;span class=&quot;err-word&quot;&gt;&#39; +
                      escapeHtml(
                        item.origin
                      ) +
                    &#39;&lt;/span&gt;&#39; +

                    &#39;&lt;span class=&quot;arrow&quot;&gt;&#39; +
                      &#39;→&#39; +
                    &#39;&lt;/span&gt;&#39; +

                    &#39;&lt;span class=&quot;fix-word&quot;&gt;&#39; +
                      escapeHtml(
                        item.revised
                      ) +
                    &#39;&lt;/span&gt;&#39; +

                    &#39;&lt;span class=&quot;fix-reason&quot;&gt;&#39; +
                      escapeHtml(
                        item.msg
                      ) +
                    &#39;&lt;/span&gt;&#39; +

                  &#39;&lt;/li&gt;&#39;
                );
              }
            )
            .join(&#39;&#39;);


        helpListHtml =
          &#39;&lt;div class=&quot;analysis-box&quot;&gt;&#39; +

            &#39;&lt;div class=&quot;analysis-title&quot;&gt;&#39; +

              &#39;&lt;span class=&quot;material-icons&quot; style=&quot;font-size:14px;&quot;&gt;&#39; +
                &#39;info&#39; +
              &#39;&lt;/span&gt;&#39; +

              &#39;상세 수정 내역 (&#39; +
              analysisList.length +
              &#39;건)&#39; +

            &#39;&lt;/div&gt;&#39; +

            &#39;&lt;ul class=&quot;analysis-list&quot;&gt;&#39; +
              listItems +
            &#39;&lt;/ul&gt;&#39; +

          &#39;&lt;/div&gt;&#39;;
      }


      itemDiv.innerHTML =

        &#39;&lt;div class=&quot;item-header&quot;&gt;&#39; +

          &#39;&lt;span&gt;&#39; +
            rowData.row +
            &#39;행 / &#39; +
            rowData.col +
            &#39;열&#39; +
          &#39;&lt;/span&gt;&#39; +

          &#39;&lt;span style=&quot;color:#1a73e8;&quot;&gt;&#39; +
            &#39;분석 완료&#39; +
          &#39;&lt;/span&gt;&#39; +

        &#39;&lt;/div&gt;&#39; +


        &#39;&lt;div class=&quot;split-view&quot;&gt;&#39; +

          &#39;&lt;div class=&quot;pane pane-left&quot;&gt;&#39; +
            diff.left +
          &#39;&lt;/div&gt;&#39; +

          &#39;&lt;div &#39; +
            &#39;class=&quot;pane pane-right&quot; &#39; +
            &#39;contenteditable=&quot;true&quot; &#39; +
            &#39;spellcheck=&quot;false&quot; &#39; +
            &#39;id=&quot;edit-&#39; +
            index +
            &#39;&quot; &#39; +
            &#39;oninput=&quot;updateBytes(&#39; +
            index +
            &#39;)&quot;&#39; +
          &#39;&gt;&#39; +

            diff.right +

          &#39;&lt;/div&gt;&#39; +

        &#39;&lt;/div&gt;&#39; +


        helpListHtml +


        &#39;&lt;div class=&quot;byte-info&quot;&gt;&#39; +

          &#39;&lt;div class=&quot;byte-box&quot; style=&quot;border-right:1px solid #eee;&quot;&gt;&#39; +
            &#39;원본: &#39; +
            getByteLength(
              rowData.original
            ) +
            &#39; byte&#39; +
          &#39;&lt;/div&gt;&#39; +

          &#39;&lt;div class=&quot;byte-box&quot;&gt;&#39; +
            &#39;수정본: &#39; +

            &#39;&lt;span &#39; +
              &#39;id=&quot;byte-&#39; +
              index +
              &#39;&quot; &#39; +
              &#39;class=&quot;byte-val&quot;&#39; +
            &#39;&gt;&#39; +

              getByteLength(
                apiResult.revised
              ) +

            &#39;&lt;/span&gt;&#39; +

            &#39; byte&#39; +

          &#39;&lt;/div&gt;&#39; +

        &#39;&lt;/div&gt;&#39;;


      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 &amp;&amp;
            block.nested.length &gt; 0
          ) {
            traverseBlocks(
              block.nested,
              helps,
              resultsArray
            );

            return;
          }


          let origin = &#39;&#39;;

          if (
            typeof block.origin ===
            &#39;string&#39;
          ) {
            origin =
              block.origin;

          } else if (
            block.origin &amp;&amp;
            block.origin.content !==
              undefined
          ) {
            origin =
              block.origin.content;
          }


          const revised =
            block.revised || &#39;&#39;;


          if (
            origin === revised
          ) {
            return;
          }


          let msg =
            &#39;교정&#39;;


          if (
            block.revisions &amp;&amp;
            block.revisions.length &gt; 0
          ) {
            const revision =
              block.revisions[0];

            const helpId =
              revision.helpId ||
              revision.help_id;


            if (
              helpId &amp;&amp;
              helps &amp;&amp;
              helps[helpId]
            ) {
              msg =
                helps[helpId].comment ||
                &#39;교정&#39;;
            }
          }


          resultsArray.push({
            origin:
              origin,

            revised:
              revised,

            msg:
              msg
          });
        }
      );
    }


    function diffWordsLib(
      text1,
      text2
    ) {
      if (
        typeof diff_match_patch ===
        &#39;undefined&#39;
      ) {
        return {
          left:
            escapeHtml(
              text1
            ),

          right:
            escapeHtml(
              text2
            )
        };
      }


      const dmp =
        new diff_match_patch();


      function tokenize(text) {
        return text.split(
          /([^\S\r\n]+|[.,;!?])/g
        );
      }


      const t1 =
        tokenize(
          text1
        );

      const t2 =
        tokenize(
          text2
        );


      const tokenMap = {};

      const tokenArr = [];

      let count = 0;


      function encode(tokens) {
        let chars = &#39;&#39;;


        for (
          let i = 0;
          i &lt; tokens.length;
          i++
        ) {
          const token =
            tokens[i];


          if (
            !Object.prototype
              .hasOwnProperty
              .call(
                tokenMap,
                token
              )
          ) {
            tokenMap[token] =
              String.fromCharCode(
                count
              );

            tokenArr[count] =
              token;

            count++;
          }


          chars +=
            tokenMap[token];
        }


        return chars;
      }


      const chars1 =
        encode(
          t1
        );

      const chars2 =
        encode(
          t2
        );


      const diffs =
        dmp.diff_main(
          chars1,
          chars2,
          false
        );


      dmp.diff_cleanupSemantic(
        diffs
      );


      let leftHtml = &#39;&#39;;

      let rightHtml = &#39;&#39;;


      diffs.forEach(
        function(part) {
          const operation =
            part[0];

          const chars =
            part[1];

          let text = &#39;&#39;;


          for (
            let i = 0;
            i &lt; chars.length;
            i++
          ) {
            text +=
              tokenArr[
                chars.charCodeAt(i)
              ];
          }


          const safeText =
            escapeHtml(
              text
            );


          if (
            operation === 0
          ) {
            leftHtml +=
              safeText;

            rightHtml +=
              safeText;

          } else if (
            operation === -1
          ) {
            leftHtml +=
              &#39;&lt;span class=&quot;diff-del&quot;&gt;&#39; +
                safeText +
              &#39;&lt;/span&gt;&#39;;

          } else if (
            operation === 1
          ) {
            rightHtml +=
              &#39;&lt;span class=&quot;diff-add&quot;&gt;&#39; +
                safeText +
              &#39;&lt;/span&gt;&#39;;
          }
        }
      );


      return {
        left:
          leftHtml,

        right:
          rightHtml
      };
    }


    function getByteLength(value) {
      let byteLength = 0;

      if (!value) {
        return 0;
      }


      for (
        let i = 0;
        i &lt; value.length;
        i++
      ) {
        const code =
          value.charCodeAt(i);


        if (
          code === 10
        ) {
          byteLength += 2;

        } else if (
          code &gt;&gt; 11
        ) {
          byteLength += 3;

        } else if (
          code &gt;&gt; 7
        ) {
          byteLength += 2;

        } else {
          byteLength += 1;
        }
      }


      return byteLength;
    }


    function updateBytes(index) {
      const element =
        document.getElementById(
          &#39;edit-&#39; + index
        );

      const byteElement =
        document.getElementById(
          &#39;byte-&#39; + index
        );


      if (
        !element ||
        !byteElement
      ) {
        return;
      }


      byteElement.innerText =
        getByteLength(
          element.innerText
        );
    }


    function updateApplyButton(
      current,
      total
    ) {
      const button =
        document.getElementById(
          &#39;btnApply&#39;
        );


      button.innerText =
        &#39;적용하기 (&#39; +
        current +
        &#39; / &#39; +
        total +
        &#39;)&#39;;


      if (
        current === total
      ) {
        button.disabled =
          false;
      }
    }


    function applyAll() {
      if (
        !confirm(
          &#39;검사된 수정본을 원본 셀에 적용하시겠습니까?\n적용 전에 오른쪽 수정 내용을 확인해 주세요.&#39;
        )
      ) {
        return;
      }


      const updates = [];


      itemData.forEach(
        function(item, index) {
          if (!item) {
            return;
          }


          const element =
            document.getElementById(
              &#39;edit-&#39; + index
            );


          if (!element) {
            return;
          }


          updates.push({
            sheetName:
              item.sheetName,

            row:
              item.row,

            col:
              item.col,

            text:
              element.innerText
          });
        }
      );


      const button =
        document.getElementById(
          &#39;btnApply&#39;
        );


      button.disabled =
        true;

      button.innerText =
        &#39;저장 중...&#39;;


      google.script.run

        .withSuccessHandler(
          function(count) {
            alert(
              &#39;✅ 총 &#39; +
              count +
              &#39;건이 수정되었습니다.&#39;
            );

            google.script.host.close();
          }
        )

        .withFailureHandler(
          function(error) {
            button.disabled =
              false;

            button.innerText =
              &#39;적용하기&#39;;

            alert(
              &#39;저장 오류: &#39; +
              getErrorMessage(error)
            );
          }
        )

        .applySpellCheckResults(
          updates
        );
    }
  &lt;/script&gt;

&lt;/body&gt;
&lt;/html&gt;


&lt;? } else if (activeView === &#39;format&#39;) { ?&gt;


&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
  &lt;base target=&quot;_top&quot;&gt;

  &lt;link
    href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot;
    rel=&quot;stylesheet&quot;
  &gt;

  &lt;link
    href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot;
    rel=&quot;stylesheet&quot;
  &gt;


  &lt;style&gt;
    * {
      box-sizing: border-box;
    }

    body {
      padding: 20px;
      font-family: &#39;Noto Sans KR&#39;, sans-serif;
      background: #f8f9fa;
      margin: 0;
      height: 100vh;
      display: flex;
      flex-direction: column;
      color: #202124;
    }

    .header-area {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      gap: 20px;
      margin-bottom: 15px;
    }

    .title {
      font-size: 20px;
      font-weight: 700;
      color: #1a73e8;
      display: flex;
      align-items: center;
      gap: 8px;
    }

    .summary {
      color: #5f6368;
      font-size: 12px;
      line-height: 1.6;
      text-align: right;
    }

    .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: 24px;
      box-shadow:
        0 1px 3px rgba(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;
      gap: 15px;
    }

    .problem-count {
      color: #d93025;
    }

    .split-view {
      display: flex;
      min-height: 130px;
    }

    .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;
    }

    .hl-quotes {
      background: #f3e8fd;
      color: #7b1fa2;
      border-bottom: 2px solid #8e24aa;
      font-weight: 700;
    }

    .hl-spaces {
      background: #fff3cd;
      border-bottom: 2px solid #e37400;
    }

    .hl-banned {
      background: #ffe6e6;
      color: #c5221f;
      border-bottom: 2px solid #c5221f;
      font-weight: 700;
    }

    .hl-parentheses {
      background: #fce8e6;
      color: #b31412;
      border: 1px solid #d93025;
      border-radius: 3px;
      font-weight: 700;
    }

    .analysis-box {
      border-top: 1px solid #eee;
      background: #fafafa;
      padding: 12px 15px;
    }

    .analysis-title {
      margin-bottom: 6px;
      color: #5f6368;
      font-size: 12px;
      font-weight: 700;
    }

    .issue-row {
      display: flex;
      align-items: flex-start;
      gap: 8px;
      padding: 7px 0;
      border-bottom: 1px dashed #e8eaed;
      font-size: 12px;
      line-height: 1.6;
    }

    .issue-row:last-child {
      border-bottom: none;
    }

    .issue-badge {
      flex-shrink: 0;
      padding: 2px 7px;
      border-radius: 4px;
      font-size: 11px;
      font-weight: 700;
    }

    .issue-quotes {
      color: #7b1fa2;
      background: #f3e8fd;
    }

    .issue-spaces {
      color: #b06000;
      background: #fff3cd;
    }

    .issue-banned {
      color: #b31412;
      background: #fce8e6;
    }

    .issue-parentheses {
      color: #b31412;
      background: #fce8e6;
    }

    .issue-message {
      color: #3c4043;
    }

    .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: 700;
      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,.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);
      }
    }

    .empty-result {
      margin-top: 30px;
      padding: 45px 20px;
      border-radius: 10px;
      background: #fff;
      color: #5f6368;
      text-align: center;
      font-size: 14px;
      line-height: 1.8;
      border: 1px solid #e8eaed;
    }

    .empty-result strong {
      color: #188038;
      font-size: 16px;
    }

    .error-msg {
      color: #d93025;
      font-weight: 700;
      padding: 20px;
      text-align: center;
      background: #fce8e6;
      border-radius: 8px;
    }
  &lt;/style&gt;
&lt;/head&gt;


&lt;body&gt;

  &lt;div class=&quot;header-area&quot;&gt;

    &lt;div class=&quot;title&quot;&gt;
      &lt;span class=&quot;material-icons&quot;&gt;
        rule
      &lt;/span&gt;

      형식 검사 및 수정
    &lt;/div&gt;


    &lt;div
      class=&quot;summary&quot;
      id=&quot;summaryText&quot;
    &gt;
      검사 준비 중...
    &lt;/div&gt;

  &lt;/div&gt;


  &lt;div
    id=&quot;mainContent&quot;
    class=&quot;content-area&quot;
  &gt;

    &lt;div
      id=&quot;loaderOverlay&quot;
      class=&quot;loader-overlay&quot;
    &gt;

      &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;

      &lt;div id=&quot;loadingText&quot;&gt;
        형식을 검사하는 중입니다...
      &lt;/div&gt;

    &lt;/div&gt;


    &lt;div id=&quot;cardsContainer&quot;&gt;&lt;/div&gt;

  &lt;/div&gt;


  &lt;div class=&quot;footer-bar&quot;&gt;

    &lt;button
      class=&quot;btn btn-cancel&quot;
      onclick=&quot;google.script.host.close()&quot;
    &gt;
      닫기
    &lt;/button&gt;


    &lt;button
      class=&quot;btn btn-apply&quot;
      id=&quot;btnApply&quot;
      onclick=&quot;applyAll()&quot;
      disabled
    &gt;
      적용하기 (0건)
    &lt;/button&gt;

  &lt;/div&gt;


  &lt;script&gt;
    const FORMAT_OPTIONS =
      &lt;?!= initialDataJson ?&gt;;


    let itemData = [];

    let renderQueue = [];

    const CHUNK_SIZE = 15;


    function escapeHtml(value) {
      return String(value)

        .replace(
          /&amp;/g,
          &#39;&amp;amp;&#39;
        )

        .replace(
          /&lt;/g,
          &#39;&amp;lt;&#39;
        )

        .replace(
          /&gt;/g,
          &#39;&amp;gt;&#39;
        )

        .replace(
          /&quot;/g,
          &#39;&amp;quot;&#39;
        )

        .replace(
          /&#39;/g,
          &#39;&amp;#39;&#39;
        );
    }


    function getErrorMessage(error) {
      if (!error) {
        return &#39;알 수 없는 오류가 발생했습니다.&#39;;
      }

      if (error.message) {
        return error.message;
      }

      return String(error)
        .replace(/^Error:\s*/, &#39;&#39;);
    }


    window.onload = function() {
      runCheck();
    };


    function runCheck() {
      document
        .getElementById(
          &#39;loaderOverlay&#39;
        )
        .style
        .display = &#39;flex&#39;;


      document
        .getElementById(
          &#39;loadingText&#39;
        )
        .innerText =
          &#39;형식을 검사하는 중입니다...&#39;;


      google.script.run

        .withSuccessHandler(
          startRenderSequence
        )

        .withFailureHandler(
          function(error) {
            document
              .getElementById(
                &#39;loaderOverlay&#39;
              )
              .innerHTML =
                &#39;&lt;div class=&quot;error-msg&quot;&gt;&#39; +
                  &#39;❌ 검사 오류&lt;br&gt;&lt;br&gt;&#39; +
                  escapeHtml(
                    getErrorMessage(error)
                  ) +
                &#39;&lt;/div&gt;&#39;;
          }
        )

        .runFormatCheck(
          FORMAT_OPTIONS
        );
    }


    function getSelectedOptionNames() {
      const names = [];


      if (
        FORMAT_OPTIONS.quotes
      ) {
        names.push(
          &#39;따옴표&#39;
        );
      }


      if (
        FORMAT_OPTIONS.banned
      ) {
        names.push(
          &#39;기재 금지어&#39;
        );
      }


      if (
        FORMAT_OPTIONS.spaces
      ) {
        names.push(
          &#39;연속 공백&#39;
        );
      }


      if (
        FORMAT_OPTIONS.parentheses
      ) {
        names.push(
          &#39;괄호 쌍&#39;
        );
      }


      return names;
    }


    function startRenderSequence(payload) {
      document
        .getElementById(
          &#39;loaderOverlay&#39;
        )
        .style
        .display = &#39;none&#39;;


      const container =
        document.getElementById(
          &#39;cardsContainer&#39;
        );


      const rows =
        payload &amp;&amp;
        payload.rows
          ? payload.rows
          : [];


      const studentCount =
        payload &amp;&amp;
        payload.studentCount
          ? payload.studentCount
          : 0;


      const checkedCellCount =
        payload &amp;&amp;
        payload.checkedCellCount
          ? payload.checkedCellCount
          : 0;


      document
        .getElementById(
          &#39;summaryText&#39;
        )
        .innerHTML =
          &#39;검사 대상 &#39; +
          studentCount +
          &#39;명, &#39; +
          checkedCellCount +
          &#39;개 셀&lt;br&gt;&#39; +
          escapeHtml(
            getSelectedOptionNames()
              .join(&#39; / &#39;)
          );


      if (
        rows.length === 0
      ) {
        container.innerHTML =
          &#39;&lt;div class=&quot;empty-result&quot;&gt;&#39; +

            &#39;&lt;strong&gt;✓ 형식 문제가 발견되지 않았습니다.&lt;/strong&gt;&#39; +

            &#39;&lt;br&gt;&lt;br&gt;&#39; +

            &#39;선택한 검사 항목을 기준으로 &#39; +
            studentCount +
            &#39;명의 내용을 확인했습니다.&#39; +

          &#39;&lt;/div&gt;&#39;;


        document
          .getElementById(
            &#39;btnApply&#39;
          )
          .disabled = true;

        return;
      }


      itemData =
        rows;


      renderQueue =
        rows.map(
          function(row, index) {
            return {
              row:
                row,

              index:
                index
            };
          }
        );


      document
        .getElementById(
          &#39;btnApply&#39;
        )
        .innerText =
          &#39;적용하기 (&#39; +
          rows.length +
          &#39;건)&#39;;


      document
        .getElementById(
          &#39;btnApply&#39;
        )
        .disabled =
          false;


      processRenderQueue();
    }


    function processRenderQueue() {
      if (
        renderQueue.length === 0
      ) {
        return;
      }


      const container =
        document.getElementById(
          &#39;cardsContainer&#39;
        );


      const chunk =
        renderQueue.splice(
          0,
          CHUNK_SIZE
        );


      chunk.forEach(
        function(entry) {
          renderFormatItem(
            container,
            entry.row,
            entry.index
          );
        }
      );


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


    function renderFormatItem(
      container,
      row,
      index
    ) {
      const issueHtml =
        buildIssueHtml(
          row.issues || []
        );


      const highlightedOriginal =
        buildHighlightedHtml(
          row.original,
          row.ranges || []
        );


      const div =
        document.createElement(
          &#39;div&#39;
        );


      div.className =
        &#39;check-item&#39;;


      div.innerHTML =

        &#39;&lt;div class=&quot;item-header&quot;&gt;&#39; +

          &#39;&lt;span&gt;&#39; +
            row.row +
            &#39;행 / &#39; +
            row.col +
            &#39;열&#39; +
          &#39;&lt;/span&gt;&#39; +

          &#39;&lt;span class=&quot;problem-count&quot;&gt;&#39; +
            &#39;형식 문제 &#39; +
            row.issues.length +
            &#39;건&#39; +
          &#39;&lt;/span&gt;&#39; +

        &#39;&lt;/div&gt;&#39; +


        &#39;&lt;div class=&quot;split-view&quot;&gt;&#39; +

          &#39;&lt;div class=&quot;pane pane-left&quot;&gt;&#39; +
            highlightedOriginal +
          &#39;&lt;/div&gt;&#39; +

          &#39;&lt;div &#39; +
            &#39;class=&quot;pane pane-right&quot; &#39; +
            &#39;contenteditable=&quot;true&quot; &#39; +
            &#39;spellcheck=&quot;false&quot; &#39; +
            &#39;id=&quot;edit-&#39; +
            index +
            &#39;&quot;&#39; +
          &#39;&gt;&lt;/div&gt;&#39; +

        &#39;&lt;/div&gt;&#39; +


        issueHtml;


      container.appendChild(
        div
      );


      const editable =
        document.getElementById(
          &#39;edit-&#39; + index
        );


      // 자동 수정 없이 원문 그대로 제시
      editable.textContent =
        row.suggested;
    }


    function buildIssueHtml(issues) {
      if (
        !issues ||
        issues.length === 0
      ) {
        return &#39;&#39;;
      }


      const rows =
        issues
          .map(
            function(issue) {
              return (

                &#39;&lt;div class=&quot;issue-row&quot;&gt;&#39; +

                  &#39;&lt;span class=&quot;issue-badge issue-&#39; +
                    escapeHtml(
                      issue.type
                    ) +
                  &#39;&quot;&gt;&#39; +

                    escapeHtml(
                      issue.title
                    ) +

                  &#39;&lt;/span&gt;&#39; +

                  &#39;&lt;span class=&quot;issue-message&quot;&gt;&#39; +

                    escapeHtml(
                      issue.message
                    ) +

                  &#39;&lt;/span&gt;&#39; +

                &#39;&lt;/div&gt;&#39;
              );
            }
          )
          .join(&#39;&#39;);


      return (
        &#39;&lt;div class=&quot;analysis-box&quot;&gt;&#39; +

          &#39;&lt;div class=&quot;analysis-title&quot;&gt;&#39; +
            &#39;검사 결과&#39; +
          &#39;&lt;/div&gt;&#39; +

          rows +

        &#39;&lt;/div&gt;&#39;
      );
    }


    function getHighlightClass(type) {
      if (
        type === &#39;quotes&#39;
      ) {
        return &#39;hl-quotes&#39;;
      }


      if (
        type === &#39;spaces&#39;
      ) {
        return &#39;hl-spaces&#39;;
      }


      if (
        type === &#39;banned&#39;
      ) {
        return &#39;hl-banned&#39;;
      }


      if (
        type === &#39;parentheses&#39;
      ) {
        return &#39;hl-parentheses&#39;;
      }


      return &#39;&#39;;
    }


    function buildHighlightedHtml(
      text,
      ranges
    ) {
      text =
        String(
          text || &#39;&#39;
        );


      if (
        !ranges ||
        ranges.length === 0
      ) {
        return escapeHtml(
          text
        );
      }


      const markers =
        new Array(
          text.length
        );


      for (
        let i = 0;
        i &lt; markers.length;
        i++
      ) {
        markers[i] =
          [];
      }


      ranges.forEach(
        function(range) {
          const className =
            getHighlightClass(
              range.type
            );


          if (!className) {
            return;
          }


          const start =
            Math.max(
              0,
              range.start
            );


          const end =
            Math.min(
              text.length,
              range.end
            );


          for (
            let i = start;
            i &lt; end;
            i++
          ) {
            if (
              markers[i]
                .indexOf(
                  className
                ) === -1
            ) {
              markers[i].push(
                className
              );
            }
          }
        }
      );


      let html = &#39;&#39;;

      let segmentStart = 0;


      let previousKey =
        markers.length &gt; 0
          ? markers[0]
              .slice()
              .sort()
              .join(&#39; &#39;)
          : &#39;&#39;;


      for (
        let i = 1;
        i &lt;= text.length;
        i++
      ) {
        const currentKey =
          i &lt; text.length
            ? markers[i]
                .slice()
                .sort()
                .join(&#39; &#39;)
            : null;


        if (
          currentKey !==
          previousKey
        ) {
          const segment =
            text.slice(
              segmentStart,
              i
            );


          const safeSegment =
            escapeHtml(
              segment
            );


          if (
            previousKey
          ) {
            html +=
              &#39;&lt;span class=&quot;&#39; +
              previousKey +
              &#39;&quot;&gt;&#39; +
              safeSegment +
              &#39;&lt;/span&gt;&#39;;

          } else {
            html +=
              safeSegment;
          }


          segmentStart =
            i;

          previousKey =
            currentKey;
        }
      }


      return html;
    }


    function applyAll() {
      if (
        !confirm(
          &#39;오른쪽 수정안으로 원본 셀을 덮어쓰시겠습니까?\n형식 검사는 자동 수정하지 않으므로 직접 고친 내용을 확인해 주세요.&#39;
        )
      ) {
        return;
      }


      const updates =
        [];


      itemData.forEach(
        function(item, index) {
          const element =
            document.getElementById(
              &#39;edit-&#39; + index
            );


          if (!element) {
            return;
          }


          updates.push({
            sheetName:
              item.sheetName,

            row:
              item.row,

            col:
              item.col,

            text:
              element.innerText
          });
        }
      );


      const button =
        document.getElementById(
          &#39;btnApply&#39;
        );


      button.disabled =
        true;

      button.innerText =
        &#39;저장 중...&#39;;


      google.script.run

        .withSuccessHandler(
          function(count) {
            alert(
              &#39;✅ 총 &#39; +
              count +
              &#39;개 셀에 수정 내용을 적용했습니다.&#39;
            );

            google.script.host.close();
          }
        )

        .withFailureHandler(
          function(error) {
            button.disabled =
              false;

            button.innerText =
              &#39;적용하기 (&#39; +
              itemData.length +
              &#39;건)&#39;;

            alert(
              &#39;저장 오류: &#39; +
              getErrorMessage(error)
            );
          }
        )

        .applySpellCheckResults(
          updates
        );
    }
  &lt;/script&gt;

&lt;/body&gt;
&lt;/html&gt;


&lt;? } ?&gt;</pre></section></section>]]></description></item><item><title>석림중학교 교직원 연수</title><link>https://민기.한국/72</link><guid isPermaLink="true">https://민기.한국/72</guid><pubDate>Wed, 09 Sep 2026 06:39:22 GMT</pubDate><description><![CDATA[<h1>석림중학교 교직원 연수</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-09-09</dd><dt>장소</dt><dd>서산석림중학교</dd></dl><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F17251833_%5B%EA%B0%95%EC%9D%98%5D_%EC%95%B1%EC%8A%A4%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8.pptx?alt=media&amp;token=71a3c38c-1dc9-4097-87e5-55f7fe085e1a">[강의] 앱스스크립트.pptx</a></li></ul></section><section><h2>바른 API</h2><section><h3>Code.gs</h3><pre>// =================================================================
// [0] 초기 설정 및 메뉴
// =================================================================

function onOpen() {
  SpreadsheetApp.getUi().createMenu(&#39;민기쌤 생기부 도우미&#39;)
      .addItem(&#39;🚀 사이드바 열기&#39;, &#39;openSidebar&#39;) 
      .addToUi();
}

function renderView_(viewName) {
  var template = HtmlService.createTemplateFromFile(&#39;Sidebar&#39;);
  template.initialView = viewName || &#39;sidebar&#39;;
  return template.evaluate();
}

function openSidebar() {
  ensureBannedWordSheet_();
  var html = renderView_(&#39;sidebar&#39;)
      .setTitle(&#39;민기쌤 생기부 맞춤법 도우미&#39;)
      .setWidth(300); 
  SpreadsheetApp.getUi().showSidebar(html);
}

function openSpellCheckViewer() {
  var html = renderView_(&#39;spellcheck&#39;)
      .setWidth(1000) 
      .setHeight(700);
  SpreadsheetApp.getUi().showModelessDialog(html, &#39;📝 Bareun 맞춤법 검사 및 수정&#39;);
}

function openBannedWordViewer() {
  var html = renderView_(&#39;banned&#39;)
      .setWidth(1000)
      .setHeight(700);
  SpreadsheetApp.getUi().showModelessDialog(html, &#39;🚫 기재 금지어 점검&#39;);
}

// =================================================================
// [1] Bareun API 키 및 UI 설정
// =================================================================

function getBareunUserProperties_() {
  ensureBannedWordSheet_();
  var props = PropertiesService.getUserProperties();
  var currentPolicy = &#39;BAREUN_ONLY_KEYS_V3&#39;;
  var previousPolicy = props.getProperty(&#39;API_KEY_POLICY_VERSION&#39;);

  // 정책 변경 시 현재 기능에서 쓰지 않는 예전 사용자 설정을 한 번 정리합니다.
  if (previousPolicy !== currentPolicy) {
    var allowedProperties = {
      UI_STATE: true,
      API_KEY_POLICY_VERSION: true
    };
    // 바로 이전 사용자 입력형 정책에서 저장한 키만 보존합니다.
    if (previousPolicy === &#39;USER_SUPPLIED_KEYS_V1&#39; ||
        previousPolicy === &#39;BAREUN_ONLY_KEYS_V1&#39; ||
        previousPolicy === &#39;BAREUN_ONLY_KEYS_V2&#39;) {
      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(&#39;UI_STATE&#39;) || &#39;{}&#39;);
    } catch (e) {
      previousUiState = {};
    }
    var currentUiState = {};
    [&#39;card-api&#39;, &#39;sub-api-settings&#39;, &#39;card-util&#39;].forEach(function(id) {
      if (previousUiState[id]) currentUiState[id] = true;
    });
    props.setProperty(&#39;UI_STATE&#39;, JSON.stringify(currentUiState));
    props.setProperty(&#39;API_KEY_POLICY_VERSION&#39;, currentPolicy);
  }

  return props;
}

function saveUserBareunKey(bareunKey) {
  var props = getBareunUserProperties_();
  var cleanBareunKey = String(bareunKey || &#39;&#39;).trim();

  if (!cleanBareunKey) {
    throw new Error(&#39;Bareun API 키를 입력해 주세요. 이 도우미는 API 키를 제공하지 않습니다.&#39;);
  }

  props.setProperty(&#39;USER_BAREUN_KEY&#39;, cleanBareunKey);
  return { hasBareun: true };
}

function checkUserBareunStatus() {
  var props = getBareunUserProperties_();
  return { hasBareun: !!props.getProperty(&#39;USER_BAREUN_KEY&#39;) };
}

function clearUserBareunKey() {
  var props = getBareunUserProperties_();
  props.deleteProperty(&#39;USER_BAREUN_KEY&#39;);
  return { hasBareun: false };
}

function getBareunKey() {
  var key = getBareunUserProperties_().getProperty(&#39;USER_BAREUN_KEY&#39;);
  if (!key) throw new Error(&quot;Bareun API 키가 없습니다. 사이드바 상단에서 등록해주세요.&quot;);
  return key;
}

// =================================================================
// [2] Bareun 맞춤법 검사
// =================================================================

function getSelectedDataForSpellCheck() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var sheetName = sheet.getName();
  var range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);
  var rows = [];
  var startRow = range.getRow();
  var startCol = range.getColumn();
  var values = range.getValues();
  for (var i = 0; i &lt; values.length; i++) {
    for (var j = 0; j &lt; values[0].length; j++) {
      var text = values[i][j].toString();
      if (text.trim() !== &quot;&quot;) {
        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(&#39;검사한 시트를 찾을 수 없습니다: &#39; + item.sheetName);
    sheet.getRange(item.row, item.col).setValue(item.text);
  });
  return updates.length;
}

function callBareunApi(text, userKey) {
  var url = &quot;https://api.bareun.ai/bareun.RevisionService/CorrectError&quot;;
  var options = {
    &quot;method&quot;: &quot;post&quot;, &quot;contentType&quot;: &quot;application/json&quot;, &quot;headers&quot;: { &quot;api-key&quot;: userKey }, &quot;muteHttpExceptions&quot;: true,
    &quot;payload&quot;: JSON.stringify({ &quot;document&quot;: { &quot;content&quot;: text, &quot;language&quot;: &quot;ko-KR&quot; }, &quot;encoding_type&quot;: &quot;UTF16&quot; })
  };
  var response = UrlFetchApp.fetch(url, options);
  var result = JSON.parse(response.getContentText());
  
  if (response.getResponseCode() !== 200) {
    return { error: &quot;API 오류: &quot; + response.getResponseCode() };
  }
  return { revised: result.revised, helps: result.helps || {}, rawBlocks: result.revisedBlocks || result.revised_blocks || [], error: null };
}

// =================================================================
// [3] 기재 금지어 관리
// =================================================================

var BANNED_WORD_SHEET_NAME_ = &#39;기재금지어관리&#39;;
var BANNED_WORD_HEADERS_ = [&#39;번호&#39;, &#39;기재 금지어&#39;];
var LEGACY_BANNED_WORD_HEADERS_ = [&#39;유형&#39;, &#39;기재 금지어&#39;, &#39;대체 표현&#39;];
var ONE_COLUMN_BANNED_WORD_HEADERS_ = [&#39;기재 금지어&#39;];

function getLegacyBannedWordList_() {
  var saved = PropertiesService.getUserProperties().getProperty(&#39;BANNED_WORDS_DB&#39;);
  if (!saved) return getDefaultBannedList_();

  try {
    var parsed = JSON.parse(saved);
    if (!Array.isArray(parsed)) return getDefaultBannedList_();
    return parsed
      .map(function(item) {
        return String(item &amp;&amp; item.k || &#39;&#39;).trim();
      })
      .filter(function(keyword) { return keyword !== &#39;&#39;; });
  } catch (e) {
    return getDefaultBannedList_();
  }
}

function initializeBannedWordSheet_(sheet, list) {
  sheet.getRange(1, 1, 1, BANNED_WORD_HEADERS_.length)
    .setValues([BANNED_WORD_HEADERS_])
    .setBackground(&#39;#fce8e6&#39;)
    .setFontColor(&#39;#a50e0e&#39;)
    .setFontWeight(&#39;bold&#39;)
    .setHorizontalAlignment(&#39;center&#39;);

  var rows = list.map(function(keyword, index) {
    return [index + 1, keyword];
  });
  if (rows.length &gt; 0) sheet.getRange(2, 1, rows.length, 2).setValues(rows);

  sheet.setFrozenRows(1);
  sheet.setColumnWidth(1, 60);
  sheet.setColumnWidth(2, 260);
  sheet.setTabColor(&#39;#d93025&#39;);
}

function migrateLegacyBannedWordSheet_(sheet) {
  var lastColumn = sheet.getLastColumn();
  var legacyColumn;
  var clearColumns;

  if (lastColumn &gt;= 3 &amp;&amp; sheet.getRange(1, 1, 1, 3).getDisplayValues()[0].join(&#39;|&#39;) === LEGACY_BANNED_WORD_HEADERS_.join(&#39;|&#39;)) {
    legacyColumn = 2;
    clearColumns = 3;
  } else if (sheet.getRange(1, 1, 1, 1).getDisplayValues()[0][0] === ONE_COLUMN_BANNED_WORD_HEADERS_[0]) {
    legacyColumn = 1;
    clearColumns = 1;
  } else {
    return false;
  }

  var lastRow = sheet.getLastRow();
  var keywords = lastRow &lt; 2 ? [] : sheet.getRange(2, legacyColumn, lastRow - 1, 1)
    .getDisplayValues()
    .map(function(row) { return String(row[0] || &#39;&#39;).trim(); })
    .filter(function(keyword) { return keyword !== &#39;&#39;; });

  var clearRows = Math.max(sheet.getLastRow(), 1);
  sheet.getRange(1, 1, clearRows, clearColumns).clearContent();
  initializeBannedWordSheet_(sheet, keywords);
  return true;
}

function ensureBannedWordSheet_() {
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  if (!spreadsheet) throw new Error(&#39;활성 스프레드시트를 찾을 수 없습니다.&#39;);

  var originalSheet = spreadsheet.getActiveSheet();
  var originalRange = spreadsheet.getActiveRange();
  var lock = LockService.getDocumentLock() || LockService.getScriptLock();
  lock.waitLock(30000);

  var sheet;
  try {
    sheet = spreadsheet.getSheetByName(BANNED_WORD_SHEET_NAME_);
    if (!sheet) {
      sheet = spreadsheet.insertSheet(BANNED_WORD_SHEET_NAME_);
      initializeBannedWordSheet_(sheet, getLegacyBannedWordList_());
    } else {
      // 이전 형식은 금지어만 보존하여 번호·기재금지어 두 열 목록으로 안전하게 바꿉니다.
      migrateLegacyBannedWordSheet_(sheet);
    }

    // 시트로 옮긴 뒤 예전 사용자별 저장값은 더 이상 별도 원본으로 남기지 않습니다.
    PropertiesService.getUserProperties().deleteProperty(&#39;BANNED_WORDS_DB&#39;);
    return sheet;
  } finally {
    if (originalSheet &amp;&amp; sheet &amp;&amp; originalSheet.getSheetId() !== sheet.getSheetId()) {
      spreadsheet.setActiveSheet(originalSheet);
      if (originalRange) spreadsheet.setActiveRange(originalRange);
    }
    lock.releaseLock();
  }
}

function openBannedWordManagementSheet() {
  var sheet = ensureBannedWordSheet_();
  sheet.activate();
  sheet.getRange(&#39;A1&#39;).activate();
  return sheet.getName();
}

function getDefaultBannedList_() {
  return [
    &quot;MBTI&quot;, &quot;커리어넷&quot;, &quot;메이저맵&quot;, &quot;패들렛&quot;, &quot;띵커벨&quot;, &quot;알로&quot;,
    &quot;미리캔버스&quot;, &quot;망고보드&quot;, &quot;유튜버&quot;, &quot;유튜브&quot;, &quot;넷플릭스&quot;, &quot;왓챠&quot;,
    &quot;티빙&quot;, &quot;네이버 폼&quot;, &quot;구글 폼&quot;, &quot;OECD&quot;, &quot;UN&quot;, &quot;유네스코&quot;, &quot;WTO&quot;,
    &quot;WHO&quot;, &quot;그린피스&quot;, &quot;앰네스티&quot;, &quot;IMF&quot;, &quot;인스타그램&quot;, &quot;페이스북&quot;,
    &quot;카카오톡&quot;, &quot;네이버&quot;, &quot;구글&quot;, &quot;클래스 카드&quot;, &quot;구글 클래스룸&quot;, &quot;게더타운&quot;,
    &quot;제페토&quot;, &quot;아이패드&quot;, &quot;갤럭시 탭&quot;, &quot;비트코인&quot;, &quot;TED&quot;, &quot;반크&quot;, &quot;TOEIC&quot;,
    &quot;TOEFL&quot;, &quot;TEPS&quot;, &quot;대회&quot;, &quot;수상&quot;, &quot;한자능력검정&quot;
  ];
}

function getBannedWordList_() {
  var sheet = ensureBannedWordSheet_();
  var header = sheet.getRange(1, 1, 1, 2).getDisplayValues()[0];
  if (header.join(&#39;|&#39;) !== BANNED_WORD_HEADERS_.join(&#39;|&#39;)) {
    throw new Error(&#39;기재금지어관리 시트의 1행은 &quot;번호 | 기재 금지어&quot; 순서여야 합니다.&#39;);
  }

  var lastRow = sheet.getLastRow();
  if (lastRow &lt; 2) return [];

  var values = sheet.getRange(2, 1, lastRow - 1, 2).getDisplayValues();
  var seen = {};
  var list = [];

  values.forEach(function(row, index) {
    var rowNumber = index + 2;
    var number = String(row[0] || &#39;&#39;).trim();
    var keyword = String(row[1] || &#39;&#39;).trim();

    if (!number &amp;&amp; !keyword) return;
    if (!/^[1-9]\d*$/.test(number)) {
      throw new Error(&#39;기재금지어관리 시트 &#39; + rowNumber + &#39;행의 번호(A열)는 1 이상의 정수여야 합니다.&#39;);
    }
    if (!keyword) throw new Error(&#39;기재금지어관리 시트 &#39; + rowNumber + &#39;행의 기재 금지어(B열)를 입력해 주세요.&#39;);

    var duplicateKey = normalizeBannedWord_(keyword);
    if (seen[duplicateKey]) {
      throw new Error(&#39;기재금지어관리 시트 &#39; + rowNumber + &#39;행의 기재 금지어가 &#39; + seen[duplicateKey] + &#39;행과 중복됩니다: &#39; + keyword);
    }
    seen[duplicateKey] = rowNumber;
    list.push(keyword);
  });

  return list;
}

function runBannedWordCheck() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var sheetName = sheet.getName();
  var range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);

  var values = range.getValues();
  var startRow = range.getRow();
  var startCol = range.getColumn();
  
  var db = getBannedWordList_();
  if (db.length === 0) return [];
  db.sort(function(a, b) { return normalizeBannedWord_(b).length - normalizeBannedWord_(a).length; });
  var canonicalByNormalizedKeyword = {};
  db.forEach(function(keyword) { canonicalByNormalizedKeyword[normalizeBannedWord_(keyword)] = keyword; });
  var combinedPattern = new RegExp(&#39;(&#39; + db.map(buildWhitespaceInsensitivePattern_).join(&#39;|&#39;) + &#39;)&#39;, &#39;gi&#39;);

  var results = [];

  for (var i = 0; i &lt; values.length; i++) {
    for (var j = 0; j &lt; values[0].length; j++) {
      var original = values[i][j].toString();
      if (!original.trim()) continue;

      var hasIssue = false;
      var logs = [];

      var counts = {};
      var match;
      while ((match = combinedPattern.exec(original)) !== null) {
        var normalizedMatch = normalizeBannedWord_(match[0]);
        var keyword = canonicalByNormalizedKeyword[normalizedMatch];
        if (keyword) {
          hasIssue = true;
          counts[keyword] = (counts[keyword] || 0) + 1;
        }
      }

      Object.keys(counts).forEach(function(keyword) {
        logs.push({ k: keyword, count: counts[keyword] });
      });

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

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

function normalizeBannedWord_(value) {
  return String(value || &#39;&#39;).replace(/\s+/g, &#39;&#39;).toLocaleLowerCase();
}

function buildWhitespaceInsensitivePattern_(keyword) {
  var compact = normalizeBannedWord_(keyword);
  if (!compact) throw new Error(&#39;비어 있는 기재 금지어는 검사할 수 없습니다.&#39;);
  return compact.split(&#39;&#39;).map(function(character) {
    return escapeRegExp(character);
  }).join(&#39;[\\s\\u00A0]*&#39;);
}

// =================================================================
// [4] 로컬 검토 유틸리티
// =================================================================

function unifyToStraightQuotes() {
  var range = SpreadsheetApp.getActiveRange();
  var ui = SpreadsheetApp.getUi();
  if (!range) throw new Error(&#39;선택된 셀이 없습니다.&#39;);
  var response = ui.alert(&#39;따옴표 변환&#39;, &#39;선택 영역의 둥근 따옴표를 곧은 따옴표로 변환하시겠습니까?&#39;, ui.ButtonSet.YES_NO);
  if (response != ui.Button.YES) return;
  try {
    range.createTextFinder(&#39;[‘’]&#39;).useRegularExpression(true).matchEntireCell(false).replaceAllWith(&quot;&#39;&quot;);
    range.createTextFinder(&#39;[“”]&#39;).useRegularExpression(true).matchEntireCell(false).replaceAllWith(&#39;&quot;&#39;);
    SpreadsheetApp.flush();
    ui.alert(&#39;✅ 변환 완료&#39;);
  } catch (e) { ui.alert(&#39;오류: &#39; + e.toString()); }
}

// [신규] 선택 영역 스페이스바 실수(2칸 이상) 수정 함수
function fixDoubleSpaces() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);

  const values = range.getValues();
  let fixCount = 0; // 수정된 셀 개수 카운트

  const newValues = values.map(row =&gt; {
    return row.map(cell =&gt; {
      let str = String(cell);
      // 공백이 2번 이상 연속( /  +/)으로 있으면 1번으로 변경
      // 줄바꿈(\n)은 건드리지 않도록 일반 공백( )만 타겟팅
      if (/ {2,}/.test(str)) {
        fixCount++;
        return str.replace(/ {2,}/g, &#39; &#39;); 
      }
      return cell;
    });
  });

  if (fixCount &gt; 0) {
    range.setValues(newValues);
  }
  
  return fixCount;
}

// =================================================================
// [5] UI 상태 저장/불러오기
// =================================================================

function saveUiState(cardId, isExpanded) {
  var props = PropertiesService.getUserProperties();
  var state = JSON.parse(props.getProperty(&#39;UI_STATE&#39;) || &#39;{}&#39;);
  state[cardId] = isExpanded;
  props.setProperty(&#39;UI_STATE&#39;, JSON.stringify(state));
}

function getUiState() {
  var props = PropertiesService.getUserProperties();
  return JSON.parse(props.getProperty(&#39;UI_STATE&#39;) || &#39;{}&#39;);
}
</pre></section><section><h3>Sidebar.html</h3><pre>&lt;? var allowedViews = [&quot;sidebar&quot;, &quot;spellcheck&quot;, &quot;banned&quot;]; ?&gt;
&lt;? var activeView = allowedViews.indexOf(initialView) &gt;= 0 ? initialView : &quot;sidebar&quot;; ?&gt;
&lt;? if (activeView === &#39;sidebar&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;style&gt;
      * { box-sizing: border-box; }
      body {
        margin: 0;
        padding: 10px 15px 10px 2px;
        background: #f1f3f4;
        color: #202124;
        font-family: &#39;Noto Sans KR&#39;, 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=&quot;password&quot;] {
        width: 100%;
        padding: 10px;
        border: 2px solid #dadce0;
        border-radius: 6px;
        outline: none;
        font-family: inherit;
        font-size: 14px;
      }
      input[type=&quot;password&quot;]: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; }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div class=&quot;card&quot; id=&quot;card-api&quot;&gt;
      &lt;div class=&quot;card-header&quot; onclick=&quot;toggleCard(this)&quot;&gt;
        &lt;div class=&quot;header-title&quot;&gt;🔑 Bareun API 키 설정&lt;/div&gt;
        &lt;span class=&quot;material-icons toggle-icon&quot;&gt;expand_more&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class=&quot;card-body&quot;&gt;
        &lt;div class=&quot;sub-section&quot; id=&quot;sub-api-settings&quot;&gt;
          &lt;div class=&quot;sub-header&quot; onclick=&quot;toggleSub(this)&quot;&gt;
            &lt;span&gt;내 Bareun API 키&lt;/span&gt;
            &lt;span class=&quot;material-icons sub-icon&quot;&gt;expand_more&lt;/span&gt;
          &lt;/div&gt;
          &lt;div class=&quot;sub-body&quot;&gt;
            &lt;div id=&quot;api-loading&quot; class=&quot;api-status&quot;&gt;⌛ API 키 설정 확인 중...&lt;/div&gt;
            &lt;div id=&quot;api-settings&quot; class=&quot;hidden&quot;&gt;
              &lt;div id=&quot;apiStatusSummary&quot; class=&quot;api-status&quot;&gt;&lt;/div&gt;
              &lt;label class=&quot;input-label&quot; for=&quot;bareunKeyInput&quot;&gt;Bareun API 키&lt;/label&gt;
              &lt;input type=&quot;password&quot; id=&quot;bareunKeyInput&quot; autocomplete=&quot;off&quot; placeholder=&quot;본인이 발급받은 키를 입력하세요&quot;&gt;
              &lt;div class=&quot;key-help&quot;&gt;공용 키는 제공하지 않습니다. 키는 사용자별 설정에 저장되고 원문을 화면으로 다시 불러오지 않습니다.&lt;/div&gt;
              &lt;button class=&quot;btn-auth&quot; id=&quot;saveKeyBtn&quot; onclick=&quot;saveBareunKey()&quot;&gt;내 Bareun 키 저장&lt;/button&gt;
              &lt;button class=&quot;btn-secondary hidden&quot; id=&quot;clearKeyBtn&quot; onclick=&quot;resetBareunKey()&quot;&gt;저장된 Bareun 키 삭제&lt;/button&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;card expanded&quot; id=&quot;card-util&quot;&gt;
      &lt;div class=&quot;card-header&quot; onclick=&quot;toggleCard(this)&quot;&gt;
        &lt;div class=&quot;header-title&quot;&gt;📝 선택 영역 검사 및 정리&lt;/div&gt;
        &lt;span class=&quot;material-icons toggle-icon&quot;&gt;expand_more&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class=&quot;card-body&quot;&gt;
        &lt;button class=&quot;btn-warning&quot; id=&quot;checkBtn&quot; onclick=&quot;runSpellCheck()&quot;&gt;선택 영역 Bareun 맞춤법 검사 🔍&lt;/button&gt;
        &lt;div id=&quot;checkStatus&quot; class=&quot;status-box&quot;&gt;&lt;/div&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;runSpaceFix(this)&quot;&gt;선택 영역 연속 공백 정리&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;convertQuotes(this)&quot;&gt;선택 영역 따옴표 통일 (&#39; &quot;)&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;runBannedCheck()&quot;&gt;선택 영역 기재 금지어 검사&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;openBannedWordManagement()&quot;&gt;기재금지어관리 시트 열기&lt;/button&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let currentApiStatus = { hasBareun: false };

      function updateApiState(status) {
        currentApiStatus = { hasBareun: !!(status &amp;&amp; status.hasBareun) };

        const summary = document.getElementById(&#39;apiStatusSummary&#39;);
        const clearButton = document.getElementById(&#39;clearKeyBtn&#39;);
        const spellCheckButton = document.getElementById(&#39;checkBtn&#39;);
        const apiCard = document.getElementById(&#39;card-api&#39;);
        const apiSub = document.getElementById(&#39;sub-api-settings&#39;);

        summary.innerHTML = currentApiStatus.hasBareun
          ? &#39;&lt;div class=&quot;ok&quot;&gt;✅ Bareun 키 저장됨&lt;/div&gt;&#39;
          : &#39;&lt;div class=&quot;missing&quot;&gt;❌ 맞춤법 검사를 사용하려면 Bareun 키가 필요합니다.&lt;/div&gt;&#39;;
        clearButton.classList.toggle(&#39;hidden&#39;, !currentApiStatus.hasBareun);
        spellCheckButton.disabled = !currentApiStatus.hasBareun;
        spellCheckButton.title = currentApiStatus.hasBareun ? &#39;&#39; : &#39;Bareun API 키를 먼저 저장해 주세요.&#39;;

        apiCard.classList.toggle(&#39;expanded&#39;, !currentApiStatus.hasBareun);
        apiSub.classList.toggle(&#39;expanded&#39;, !currentApiStatus.hasBareun);
      }

      function toggleCard(header) {
        const card = header.parentElement;
        const isExpanded = card.classList.toggle(&#39;expanded&#39;);
        google.script.run.saveUiState(card.id, isExpanded);
      }

      function toggleSub(header) {
        const section = header.parentElement;
        const isExpanded = section.classList.toggle(&#39;expanded&#39;);
        google.script.run.saveUiState(section.id, isExpanded);
      }

      function saveBareunKey() {
        const button = document.getElementById(&#39;saveKeyBtn&#39;);
        const keyInput = document.getElementById(&#39;bareunKeyInput&#39;);
        const key = keyInput.value.trim();
        if (!key) {
          alert(&#39;Bareun API 키를 입력해 주세요.&#39;);
          return;
        }

        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;저장 중...&#39;;

        google.script.run
          .withSuccessHandler(function(status) {
            keyInput.value = &#39;&#39;;
            updateApiState(status);
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;✅ Bareun API 키를 사용자 설정에 저장했습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;Bareun API 키 저장 오류: &#39; + error);
          })
          .saveUserBareunKey(key);
      }

      function resetBareunKey() {
        if (!confirm(&#39;저장된 Bareun API 키를 삭제하시겠습니까?&#39;)) return;

        google.script.run
          .withSuccessHandler(function(status) {
            document.getElementById(&#39;bareunKeyInput&#39;).value = &#39;&#39;;
            updateApiState(status);
            alert(&#39;저장된 Bareun API 키를 삭제했습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            alert(&#39;Bareun API 키 삭제 오류: &#39; + error);
          })
          .clearUserBareunKey();
      }

      function runSpellCheck() {
        if (!currentApiStatus.hasBareun) {
          alert(&#39;Bareun API 키를 먼저 저장해 주세요.&#39;);
          return;
        }
        document.getElementById(&#39;checkStatus&#39;).innerText = &#39;맞춤법 검사 창을 여는 중...&#39;;
        google.script.run
          .withSuccessHandler(function() {
            document.getElementById(&#39;checkStatus&#39;).innerText = &#39;&#39;;
          })
          .withFailureHandler(function(error) {
            document.getElementById(&#39;checkStatus&#39;).innerText = &#39;&#39;;
            alert(&#39;오류 발생: &#39; + error);
          })
          .openSpellCheckViewer();
      }

      function runSpaceFix(button) {
        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;정리 중...&#39;;
        google.script.run
          .withSuccessHandler(function(count) {
            button.disabled = false;
            button.innerText = originalText;
            alert(count ? &#39;✅ 총 &#39; + count + &#39;개 셀의 연속 공백을 정리했습니다.&#39; : &#39;수정할 연속 공백이 없습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;오류 발생: &#39; + error);
          })
          .fixDoubleSpaces();
      }

      function convertQuotes(button) {
        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;변환 중...&#39;;
        google.script.run
          .withSuccessHandler(function() {
            button.disabled = false;
            button.innerText = originalText;
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;오류 발생: &#39; + error);
          })
          .unifyToStraightQuotes();
      }

      function runBannedCheck() {
        google.script.run
          .withFailureHandler(function(error) { alert(&#39;오류 발생: &#39; + error); })
          .openBannedWordViewer();
      }

      function openBannedWordManagement() {
        google.script.run
          .withFailureHandler(function(error) { alert(&#39;오류 발생: &#39; + error); })
          .openBannedWordManagementSheet();
      }

      window.onload = function() {
        google.script.run
          .withSuccessHandler(function(status) {
            document.getElementById(&#39;api-loading&#39;).classList.add(&#39;hidden&#39;);
            document.getElementById(&#39;api-settings&#39;).classList.remove(&#39;hidden&#39;);
            updateApiState(status);

            google.script.run.withSuccessHandler(function(states) {
              Object.keys(states || {}).forEach(function(id) {
                const element = document.getElementById(id);
                if (id === &#39;card-api&#39; || id === &#39;sub-api-settings&#39;) return;
                if (element &amp;&amp; states[id]) element.classList.add(&#39;expanded&#39;);
              });
              updateApiState(status);
            }).getUiState();
          })
          .withFailureHandler(function(error) {
            document.getElementById(&#39;api-loading&#39;).classList.add(&#39;hidden&#39;);
            document.getElementById(&#39;api-settings&#39;).classList.remove(&#39;hidden&#39;);
            updateApiState({ hasBareun: false });
            document.getElementById(&#39;apiStatusSummary&#39;).textContent = &#39;설정 확인 오류: &#39; + error;
          })
          .checkUserBareunStatus();
      };
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } else if (activeView === &#39;spellcheck&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch.js&quot;&gt;&lt;/script&gt;
    &lt;style&gt;
      body { padding: 20px; font-family: &#39;Noto Sans KR&#39;, 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; }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;

    &lt;div class=&quot;header-area&quot;&gt;
      &lt;div class=&quot;title&quot;&gt;&lt;span class=&quot;material-icons&quot;&gt;fact_check&lt;/span&gt; 맞춤법 검사 및 수정&lt;/div&gt;
      &lt;div style=&quot;font-size:12px; color:#5f6368; display:flex; gap:15px;&quot;&gt;
        &lt;div&gt;&lt;span style=&quot;background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;&quot;&gt;원본 오류&lt;/span&gt;&lt;/div&gt;
        &lt;div&gt;&lt;span style=&quot;background:#e6f4ea; color:#188038; border-bottom:2px solid #188038; padding:0 4px; font-weight:500;&quot;&gt;수정 제안&lt;/span&gt;&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div id=&quot;mainContent&quot; class=&quot;content-area&quot;&gt;
      &lt;div id=&quot;loaderOverlay&quot; class=&quot;loader-overlay&quot;&gt;
        &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
        &lt;div id=&quot;loadingText&quot;&gt;데이터를 불러오는 중입니다...&lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;cardsContainer&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;footer-bar&quot;&gt;
      &lt;button class=&quot;btn btn-cancel&quot; onclick=&quot;google.script.host.close()&quot;&gt;닫기&lt;/button&gt;
      &lt;button class=&quot;btn btn-apply&quot; id=&quot;btnApply&quot; onclick=&quot;applyAll()&quot; disabled&gt;적용하기 (0건)&lt;/button&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let itemData = []; 
      let isFirstRender = true; 

      window.onload = function() {
        google.script.run
          .withSuccessHandler(processData)
          .withFailureHandler(function(err) {
             document.getElementById(&#39;loaderOverlay&#39;).innerHTML = 
               `&lt;div class=&quot;error-msg&quot;&gt;❌ 초기화 오류&lt;br&gt;${err}&lt;br&gt;선택된 셀이 있는지 확인해주세요.&lt;/div&gt;`;
          })
          .getSelectedDataForSpellCheck();
      };

      function processData(rows) {
        if (rows.length === 0) {
          document.getElementById(&#39;loaderOverlay&#39;).innerHTML = &#39;&lt;div class=&quot;error-msg&quot;&gt;선택된 셀이 없거나 빈 셀입니다.&lt;/div&gt;&#39;;
          return;
        }

        document.getElementById(&#39;loadingText&#39;).innerText = `총 ${rows.length}개 항목 분석 중...`;
        const container = document.getElementById(&#39;cardsContainer&#39;);
        container.innerHTML = &quot;&quot;; 
        
        let processedCount = 0;
        const total = rows.length;

        rows.forEach((row, index) =&gt; {
          const div = document.createElement(&#39;div&#39;);
          div.id = `item-${index}`;
          div.className = &#39;check-item&#39;;
          div.innerHTML = `
            &lt;div class=&quot;item-header&quot;&gt;&lt;span&gt;행 ${row.row} (대기 중...)&lt;/span&gt;&lt;/div&gt;
            &lt;div class=&quot;split-view&quot;&gt;&lt;div class=&quot;pane pane-left&quot;&gt;...&lt;/div&gt;&lt;div class=&quot;pane pane-right&quot;&gt;...&lt;/div&gt;&lt;/div&gt;
          `;
          container.appendChild(div);

          google.script.run
            .withSuccessHandler((res) =&gt; {
              renderResult(index, row, res);
              processedCount++;
              updateApplyButton(processedCount, total);
            })
            .withFailureHandler((err) =&gt; {
              renderError(index, row, err);
              processedCount++;
              updateApplyButton(processedCount, total);
            })
            .getBareunCorrection(row.original);
        });
      }

      function renderError(index, rowData, errorMsg) {
        if (isFirstRender) {
          document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
          isFirstRender = false;
        }
        const itemDiv = document.getElementById(`item-${index}`);
        if (!itemDiv) return;
        itemDiv.innerHTML = `&lt;div style=&quot;padding:20px; color:red;&quot;&gt;❌ 분석 실패: ${errorMsg}&lt;/div&gt;`;
      }

      function renderResult(index, rowData, apiResult) {
        if (isFirstRender) {
          document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
          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 = &quot;&quot;;
        const analysisList = [];
        if (apiResult.rawBlocks &amp;&amp; apiResult.rawBlocks.length &gt; 0) {
          traverseBlocks(apiResult.rawBlocks, apiResult.helps, analysisList);
        }

        if (analysisList.length &gt; 0) {
          const listItems = analysisList.map(item =&gt; `
            &lt;li class=&quot;analysis-row&quot;&gt;
              &lt;span class=&quot;err-word&quot;&gt;${item.origin}&lt;/span&gt;
              &lt;span class=&quot;arrow&quot;&gt;→&lt;/span&gt;
              &lt;span class=&quot;fix-word&quot;&gt;${item.revised}&lt;/span&gt;
              &lt;span class=&quot;fix-reason&quot;&gt;${item.msg}&lt;/span&gt;
            &lt;/li&gt;
          `).join(&quot;&quot;);

          helpListHtml = `
            &lt;div class=&quot;analysis-box&quot;&gt;
              &lt;div class=&quot;analysis-title&quot;&gt;&lt;span class=&quot;material-icons&quot; style=&quot;font-size:14px;&quot;&gt;info&lt;/span&gt; 상세 수정 내역 (${analysisList.length}건)&lt;/div&gt;
              &lt;ul class=&quot;analysis-list&quot;&gt;${listItems}&lt;/ul&gt;
            &lt;/div&gt;
          `;
        }

        itemDiv.innerHTML = `
          &lt;div class=&quot;item-header&quot;&gt;
            &lt;span&gt;행 ${rowData.row}&lt;/span&gt;
            &lt;span style=&quot;color:#1a73e8;&quot;&gt;분석 완료&lt;/span&gt;
          &lt;/div&gt;
          &lt;div class=&quot;split-view&quot;&gt;
            &lt;div class=&quot;pane pane-left&quot;&gt;${diff.left}&lt;/div&gt;
            &lt;div class=&quot;pane pane-right&quot; contenteditable=&quot;true&quot; spellcheck=&quot;false&quot; id=&quot;edit-${index}&quot; oninput=&quot;updateBytes(${index})&quot;&gt;${diff.right}&lt;/div&gt;
          &lt;/div&gt;
          ${helpListHtml}
          &lt;div class=&quot;byte-info&quot;&gt;
            &lt;div class=&quot;byte-box&quot; style=&quot;border-right:1px solid #eee;&quot;&gt;원본: ${getByteLength(rowData.original)} byte&lt;/div&gt;
            &lt;div class=&quot;byte-box&quot;&gt;수정본: &lt;span id=&quot;byte-${index}&quot; class=&quot;byte-val&quot;&gt;${getByteLength(apiResult.revised)}&lt;/span&gt; byte&lt;/div&gt;
          &lt;/div&gt;
        `;
        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 &amp;&amp; block.nested.length &gt; 0) {
            traverseBlocks(block.nested, helps, resultsArray);
          } else {
            const origin = block.origin.content || &quot;&quot;;
            const revised = block.revised || &quot;&quot;;
            if (origin === revised) return;
            let msg = &quot;교정&quot;;
            if (block.revisions &amp;&amp; block.revisions.length &gt; 0) {
              const helpId = block.revisions[0].helpId || block.revisions[0].help_id;
              if (helps &amp;&amp; !helps[helpId]) return;
              if (helpId &amp;&amp; helps &amp;&amp; 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 === &#39;undefined&#39;) {
           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 = &quot;&quot;;
          for (var i = 0; i &lt; 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 = &quot;&quot;;
        let rightHtml = &quot;&quot;;
        
        diffs.forEach(function(part) {
          var op = part[0]; 
          var chars = part[1];
          var text = &quot;&quot;;
          
          // 유니코드를 다시 원래 단어로 복원
          for (var i = 0; i &lt; chars.length; i++) {
            text += tokenArr[chars.charCodeAt(i)];
          }
          
          if (op === 0) {
            leftHtml += text; rightHtml += text;
          } else if (op === -1) {
            leftHtml += `&lt;span class=&quot;diff-del&quot;&gt;${text}&lt;/span&gt;`;
          } else if (op === 1) {
            rightHtml += `&lt;span class=&quot;diff-add&quot;&gt;${text}&lt;/span&gt;`;
          }
        });
        
        return { left: leftHtml, right: rightHtml };
      }

      function getByteLength(s) {
        let b = 0; if (!s) return 0;
        for (let i = 0; i &lt; s.length; i++) {
          const c = s.charCodeAt(i);
          if (c === 10) b += 2; else if (c &gt;&gt; 11) b += 3; else if (c &gt;&gt; 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(&#39;btnApply&#39;);
        btn.innerText = `적용하기 (${current} / ${total})`;
        if (current === total) btn.disabled = false;
      }

      function applyAll() {
        if (!confirm(&quot;검사된 내용으로 원본 데이터를 덮어쓰시겠습니까?\n(되돌릴 수 없습니다)&quot;)) return;
        const updates = [];
        itemData.forEach((item, index) =&gt; {
          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(&#39;btnApply&#39;);
        btn.disabled = true; btn.innerText = &quot;저장 중...&quot;;
        google.script.run.withSuccessHandler((count) =&gt; {
          alert(`✅ 총 ${count}건이 수정되었습니다.`);
          google.script.host.close();
        }).applySpellCheckResults(updates);
      }
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } else if (activeView === &#39;banned&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;style&gt;
      body { padding: 20px; font-family: &#39;Noto Sans KR&#39;, 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: &quot;•&quot;; 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); } }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;

    &lt;div class=&quot;header-area&quot;&gt;
      &lt;div class=&quot;title&quot;&gt;&lt;span class=&quot;material-icons&quot;&gt;block&lt;/span&gt; 기재 금지어 검사&lt;/div&gt;
      &lt;div id=&quot;checkLegend&quot; style=&quot;font-size:12px; color:#5f6368; display:flex; gap:15px;&quot;&gt;
        &lt;div&gt;&lt;span style=&quot;background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;&quot;&gt;금지어 발견&lt;/span&gt;&lt;/div&gt;
        &lt;div&gt;오른쪽 수정안에서 문맥에 맞게 직접 고쳐 주세요.&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div id=&quot;checkView&quot; class=&quot;content-area&quot;&gt;
      &lt;div id=&quot;loaderOverlay&quot; class=&quot;loader-overlay&quot;&gt;
        &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
        &lt;div id=&quot;loadingText&quot;&gt;데이터를 분석 중입니다...&lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;cardsContainer&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;footer-bar&quot; id=&quot;footerCheck&quot;&gt;
      &lt;button class=&quot;btn btn-cancel&quot; onclick=&quot;google.script.host.close()&quot;&gt;닫기&lt;/button&gt;
      &lt;button class=&quot;btn btn-apply&quot; id=&quot;btnApply&quot; onclick=&quot;applyAll()&quot; disabled&gt;적용하기 (0건)&lt;/button&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let itemData = []; 
      let renderQueue = [];
      const CHUNK_SIZE = 20;

      window.onload = function() {
        runCheck();
      };

      function runCheck() {
        document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;flex&#39;;
        document.getElementById(&#39;loadingText&#39;).innerText = &quot;데이터를 분석 중입니다...&quot;;
        document.getElementById(&#39;cardsContainer&#39;).innerHTML = &quot;&quot;;
        
        google.script.run
          .withSuccessHandler(startRenderSequence)
          .withFailureHandler(err =&gt; alert(&quot;오류: &quot; + err))
          .runBannedWordCheck();
      }

      function startRenderSequence(rows) {
        document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
        const container = document.getElementById(&#39;cardsContainer&#39;);
        
        if (!rows || rows.length === 0) {
          container.innerHTML = &#39;&lt;div style=&quot;text-align:center; padding:50px; color:#5f6368;&quot;&gt;금지어가 발견되지 않았습니다. 🎉&lt;/div&gt;&#39;;
          document.getElementById(&#39;btnApply&#39;).disabled = true;
          return;
        }

        itemData = rows; 
        renderQueue = [...rows]; 
        processRenderQueue(); 

        document.getElementById(&#39;btnApply&#39;).innerText = `적용하기 (${rows.length}건)`;
        document.getElementById(&#39;btnApply&#39;).disabled = false;
      }

      function processRenderQueue() {
        if (renderQueue.length === 0) return;

        const container = document.getElementById(&#39;cardsContainer&#39;);
        const chunk = renderQueue.splice(0, CHUNK_SIZE);
        
        chunk.forEach((row) =&gt; {
          const originalIndex = itemData.findIndex(r =&gt; r.row === row.row &amp;&amp; r.col === row.col);
          let logHtml = &quot;&quot;;
          if (row.logs &amp;&amp; row.logs.length &gt; 0) {
            const listItems = row.logs.map(log =&gt; {
               const count = log.count &gt; 1 ? ` (${log.count}회)` : &#39;&#39;;
               return `&lt;li class=&quot;analysis-row&quot;&gt;&lt;span&gt;&lt;span class=&quot;err-word&quot;&gt;${escapeHtml(log.k)}&lt;/span&gt;${count} 기재금지어가 발견되었습니다. 오른쪽 수정안에서 문맥에 맞게 직접 고쳐 주세요.&lt;/span&gt;&lt;/li&gt;`;
            }).join(&quot;&quot;);
            
            logHtml = `
              &lt;div class=&quot;analysis-box&quot;&gt;
                &lt;ul class=&quot;analysis-list&quot;&gt;${listItems}&lt;/ul&gt;
              &lt;/div&gt;
            `;
          }

          const div = document.createElement(&#39;div&#39;);
          div.className = &#39;check-item&#39;;
          div.innerHTML = `
            &lt;div class=&quot;item-header&quot;&gt;
              &lt;span&gt;행 ${row.row}&lt;/span&gt;
              &lt;span style=&quot;color:#d93025; font-weight:bold;&quot;&gt;금지어 발견&lt;/span&gt;
            &lt;/div&gt;
            &lt;div class=&quot;split-view&quot;&gt;
              &lt;div class=&quot;pane pane-left&quot;&gt;${highlightBannedWords(row.original, row.logs)}&lt;/div&gt;
              &lt;div class=&quot;pane pane-right&quot; contenteditable=&quot;true&quot; spellcheck=&quot;false&quot; id=&quot;edit-${originalIndex}&quot;&gt;&lt;/div&gt;
            &lt;/div&gt;
            ${logHtml}
          `;
          container.appendChild(div);
          // 원문을 그대로 넣습니다. 기재금지어 검사는 자동 대체·삭제를 전혀 하지 않습니다.
          document.getElementById(`edit-${originalIndex}`).textContent = row.original;
        });

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

      function escapeHtml(value) {
        return String(value)
          .replace(/&amp;/g, &#39;&amp;amp;&#39;)
          .replace(/&lt;/g, &#39;&amp;lt;&#39;)
          .replace(/&gt;/g, &#39;&amp;gt;&#39;)
          .replace(/&quot;/g, &#39;&amp;quot;&#39;)
          .replace(/&#39;/g, &#39;&amp;#39;&#39;);
      }

      function escapeRegExp(value) {
        return String(value).replace(/[.*+?^${}()|[\]\\]/g, &#39;\\$&amp;&#39;);
      }

      function highlightBannedWords(text, logs) {
        const keywords = (logs || []).map(log =&gt; log.k).filter(Boolean);
        if (keywords.length === 0) return escapeHtml(text);
        const fragments = keywords
          .sort((a, b) =&gt; compactBannedWord(b).length - compactBannedWord(a).length)
          .map(buildWhitespaceInsensitivePattern);
        const pattern = new RegExp(`(${fragments.join(&#39;|&#39;)})`, &#39;gi&#39;);
        return String(text).split(pattern).map((part, index) =&gt; {
          const safeText = escapeHtml(part);
          return index % 2 === 1 ? `&lt;span class=&quot;diff-del&quot;&gt;${safeText}&lt;/span&gt;` : safeText;
        }).join(&#39;&#39;);
      }

      function compactBannedWord(value) {
        return String(value || &#39;&#39;).replace(/\s+/g, &#39;&#39;).toLocaleLowerCase();
      }

      function buildWhitespaceInsensitivePattern(keyword) {
        return compactBannedWord(keyword)
          .split(&#39;&#39;)
          .map(escapeRegExp)
          .join(&#39;[\\s\\u00A0]*&#39;);
      }

      function applyAll() {
        if (!confirm(&quot;수정된 내용으로 원본을 덮어쓰시겠습니까?&quot;)) return;
        
        const updates = [];
        itemData.forEach((item, index) =&gt; {
          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) =&gt; {
           alert(`${c}건 수정 완료!`);
           google.script.host.close();
        }).applySpellCheckResults(updates);
      }

    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } ?&gt;
</pre></section></section>]]></description></item><item><title>성환중학교 교직원 연수</title><link>https://민기.한국/66</link><guid isPermaLink="true">https://민기.한국/66</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>성환중학교 교직원 연수</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-08-26</dd><dt>장소</dt><dd>성환중학교</dd></dl><p>성환중학교 교직원 대상 바이브 코딩, 앱스스크립트, 웹 앱 만들기 연수</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F1429403_%5B%EA%B0%95%EC%9D%98%5D_%EC%84%B1%ED%99%98%EC%A4%91%ED%95%99%EA%B5%90-%EC%95%B1%EC%8A%A4%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8.pdf?alt=media&amp;token=5cd9b534-4e20-4ca6-9aba-03ff77cad01e">[강의] 성환중학교-앱스스크립트.pdf</a></li></ul></section>]]></description></item><item><title>2026 AI·디지털 교육 역량강화 연수(국어 심화) - 8. 12. 한서대학교</title><link>https://민기.한국/training/20260812-korean-advanced-hanseo</link><guid isPermaLink="true">https://민기.한국/training/20260812-korean-advanced-hanseo</guid><pubDate>Tue, 11 Aug 2026 12:33:33 GMT</pubDate><description><![CDATA[<h1>2026 AI·디지털 교육 역량강화 연수(국어 심화) - 8. 12. 한서대학교</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-08-12</dd><dt>장소</dt><dd>한서대학교 인곡관 206호</dd></dl><p>충청남도교육청 주관 연수입니다.</p><section><h2>관련 링크</h2><ul><li><a href="https://t-portalapp.netlify.app/">사전 설문</a></li><li><a href="https://t-portalapp.netlify.app/">만족도 설문</a></li></ul></section><section><h2>(1차시) 마주온 활용</h2><h3>교안 및 학습자료</h3><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F1786407792353_3621111__%EA%B0%95%EC%9D%98_%EA%B9%80%EB%AF%BC%EA%B8%B0_%EB%A7%88%EC%A3%BC%EC%98%A8_%ED%99%9C%EC%9A%A9.pdf?alt=media&amp;token=28ddd45c-aacf-4dd2-96d3-dcfaab8430d5">[강의] 김민기_마주온 활용.pdf</a></li></ul></section><section><h2>(2차시) 토의·토론</h2><h3>교안 및 학습자료</h3><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F1786407850805_349071__%EA%B0%95%EC%9D%98_%EA%B9%80%EB%AF%BC%EA%B8%B0_%EA%B5%AD%EC%96%B4%EA%B3%BC_AI_%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C%EC%9D%98_%EC%A3%BC%EC%B2%B4%EC%84%B1_%ED%86%A0%EB%A1%A0.pdf?alt=media&amp;token=d5515dda-fb31-441d-974c-03e9816435b1">[강의] 김민기_국어과 AI 환경에서의 주체성 토론.pdf</a></li></ul></section><section><h2>(3차시) AI 융합 수업 설계</h2><section><h3>Code.gs</h3><pre>const YT_CFG = Object.freeze({
  API_BASE: &#39;https://www.googleapis.com/youtube/v3&#39;,

  API_KEY_PROP: &#39;YT_API_KEY&#39;,
  STATE_PROP: &#39;YT_COMMENT_COLLECTOR_STATE&#39;,
  LAST_RESULT_PROP: &#39;YT_COMMENT_COLLECTOR_LAST_RESULT&#39;,
  CANCEL_REQUEST_PROP: &#39;YT_COMMENT_COLLECTOR_CANCEL_REQUEST&#39;,

  OUTPUT_SHEET_PREFIX: &#39;시트&#39;,
  INTERNAL_SHEET: &#39;_댓글수집_내부&#39;,
  LEGACY_LOG_SHEET: &#39;수집로그&#39;,

  CONTINUE_HANDLER: &#39;continueCommentCollection&#39;,
  STRUCTURED_ERROR_PREFIX: &#39;YT_STRUCTURED_ERROR::&#39;,
  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: [
    &#39;번호&#39;,
    &#39;채널명&#39;,
    &#39;영상 제목&#39;,
    &#39;댓글 작성자&#39;,
    &#39;댓글 내용&#39;,
    &#39;좋아요 수&#39;,
    &#39;작성 시각&#39;,
    &#39;글자수&#39;
  ],

  // 숨김 내부 시트의 열 구조입니다.
  INTERNAL_HEADERS: [
    &#39;comment_id&#39;,
    &#39;parent_comment_id&#39;,
    &#39;display_number&#39;,
    &#39;comment_type&#39;,
    &#39;reply_status&#39;,
    &#39;next_reply_index&#39;,
    &#39;total_reply_count&#39;,
    &#39;reply_page_token&#39;
  ],

  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(&#39;YouTube 댓글 수집&#39;)
    .addItem(&#39;사이드바 열기&#39;, &#39;showYouTubeCommentSidebar&#39;)
    .addSeparator()
    .addItem(&#39;중단 지점부터 실행&#39;, &#39;continueCommentCollection&#39;)
    .addItem(&#39;진행 중인 수집 취소&#39;, &#39;cancelCommentCollection&#39;)
    .addToUi();
}


/**
 * 오른쪽 사이드바를 엽니다.
 */
function showYouTubeCommentSidebar() {
  const html = HtmlService
    .createHtmlOutputFromFile(&#39;Sidebar&#39;)
    .setTitle(&#39;YouTube 댓글 수집기&#39;);

  SpreadsheetApp.getUi().showSidebar(html);
}


/**
 * 사이드바 초기 상태를 반환합니다.
 */
function getSidebarState() {
  const props = getUserProperties_();
  const apiKey = props.getProperty(YT_CFG.API_KEY_PROP) || &#39;&#39;;
  const activeState = loadState_();
  const lastResult = loadLastResult_();
  const cancelRequested =
    props.getProperty(YT_CFG.CANCEL_REQUEST_PROP) === &#39;true&#39;;

  const progress = activeState
    ? buildProgressForClient_(activeState, &#39;RUNNING&#39;)
    : lastResult
      ? buildProgressForClient_(lastResult, lastResult.status || &#39;COMPLETED&#39;)
      : null;

  if (progress &amp;&amp; activeState &amp;&amp; cancelRequested) {
    progress.stage = &#39;취소 처리 중&#39;;
    progress.message = &#39;현재 실행 구간이 끝나는 즉시 안전하게 중단됩니다.&#39;;
  }

  return {
    apiKeyConfigured: Boolean(apiKey),
    apiKeyMask: apiKey ? maskApiKey_(apiKey) : &#39;&#39;,
    running: Boolean(activeState),
    cancelRequested: cancelRequested,
    progress: progress
  };
}


/**
 * API 키를 실제 YouTube Data API 요청으로 검증한 뒤 사용자별 속성에 저장합니다.
 */
function saveApiKey(apiKey) {
  const cleanedKey = String(apiKey || &#39;&#39;).trim();

  if (!cleanedKey) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_REQUIRED&#39;,
      &#39;API 키를 입력해야 합니다.&#39;,
      &#39;Google Cloud에서 발급한 YouTube Data API 키가 입력되지 않았습니다.&#39;,
      [&#39;API 키 입력란이 비어 있습니다.&#39;],
      [
        &#39;Google Cloud Console에서 API 키를 발급합니다.&#39;,
        &#39;API 키 전체를 사이드바 입력란에 붙여 넣습니다.&#39;,
        &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 입력&#39;
      }
    ));
  }

  if (cleanedKey.length &lt; 20) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_FORMAT_INVALID&#39;,
      &#39;API 키 형식을 확인해야 합니다.&#39;,
      &#39;입력한 문자열이 일반적인 Google API 키 길이보다 짧습니다.&#39;,
      [
        &#39;API 키 일부만 복사했을 수 있습니다.&#39;,
        &#39;프로젝트 ID나 다른 값을 입력했을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;키 유형이 ‘API 키’인지 확인합니다.&#39;,
        &#39;API 키 값을 처음부터 끝까지 다시 복사하여 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 다시 입력&#39;
      }
    ));
  }

  validateApiKey_(cleanedKey);

  const props = getUserProperties_();
  props.setProperty(YT_CFG.API_KEY_PROP, cleanedKey);

  // 이전 API 키 오류 안내가 새 키 저장 뒤 다시 나타나지 않도록 정리합니다.
  const previous = loadLastResult_();
  if (previous &amp;&amp; previous.status === &#39;ERROR&#39;) {
    props.deleteProperty(YT_CFG.LAST_RESULT_PROP);
  }

  return getSidebarState();
}


/**
 * 저장된 API 키를 삭제합니다.
 */
function deleteApiKey() {
  if (loadState_()) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_DELETE_BLOCKED&#39;,
      &#39;수집 중에는 API 키를 삭제할 수 없습니다.&#39;,
      &#39;현재 댓글 수집 작업이 저장된 API 키를 사용하고 있습니다.&#39;,
      [&#39;진행 중인 작업이 존재합니다.&#39;],
      [
        &#39;현재 수집을 완료하거나 취소합니다.&#39;,
        &#39;수집 상태가 종료된 뒤 API 키를 삭제합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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_(
        &#39;SPREADSHEET_NOT_FOUND&#39;,
        &#39;댓글을 저장할 스프레드시트를 찾지 못했습니다.&#39;,
        &#39;현재 Apps Script가 스프레드시트와 연결된 상태인지 확인해야 합니다.&#39;,
        [
          &#39;독립 실행형 Apps Script 프로젝트에서 코드를 실행했을 수 있습니다.&#39;,
          &#39;스프레드시트가 닫히거나 접근 권한이 변경되었을 수 있습니다.&#39;
        ],
        [
          &#39;댓글을 저장할 Google 스프레드시트를 엽니다.&#39;,
          &#39;‘확장 프로그램 → Apps Script’에서 이 코드를 실행합니다.&#39;,
          &#39;스프레드시트를 새로고침하고 사이드바를 다시 엽니다.&#39;
        ],
        { actionLabel: &#39;확인&#39; }
      ));
    }

    // 영상과 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: &#39;RUNNING&#39;,
      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: &#39;&#39;,
      threadDone: false,

      currentReplyParentId: &#39;&#39;,
      nextReplyPageToken: &#39;&#39;,

      topCount: 0,
      replyCount: 0,
      threadPages: 0,
      replyPages: 0,
      apiCalls: 1,

      startedAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      message: outputSheet.getName() + &#39;에서 수집을 시작했습니다.&#39;,
      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 &lt; 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 || &#39;&#39;;
        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 = &#39;실행 시간을 고려하여 진행 상태를 저장했습니다.&#39;;
    saveState_(state);
    scheduleContinuation_();

  } catch (error) {
    deleteContinuationTriggers_();

    const errorInfo = normalizeErrorInfo_(error, &#39;댓글 수집&#39;);

    if (errorInfo.code === &#39;API_KEY_INVALID&#39;) {
      getUserProperties_().deleteProperty(YT_CFG.API_KEY_PROP);
    }

    if (commentsSheet) {
      try {
        finalizeCommentsSheet_(commentsSheet);
      } catch (formatError) {
        console.error(formatError);
      }
    }

    if (state) {
      state.status = &#39;ERROR&#39;;
      state.updatedAt = new Date().toISOString();
      state.message = errorInfo.summary;
      state.error = errorInfo;
      saveLastResult_(state, &#39;ERROR&#39;, 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, &#39;true&#39;);
  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 &lt;= 0) {
    state.threadDone = true;
    state.nextThreadPageToken = &#39;&#39;;
    return;
  }

  const params = {
    part: state.includeReplies ? &#39;snippet,replies&#39; : &#39;snippet&#39;,
    videoId: state.videoId,
    maxResults: Math.min(100, remaining),
    textFormat: &#39;plainText&#39;
  };

  if (state.nextThreadPageToken) {
    params.pageToken = state.nextThreadPageToken;
  }

  const data = youtubeGet_(&#39;commentThreads&#39;, 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 &gt;= 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 = &#39;DONE&#39;;
    let nextReplyIndex = 1;

    commentRows.push(
      commentToOutputRow_(state, topComment, topNumber)
    );

    batchIds.add(topCommentId);
    topAdded += 1;

    const inlineReplies =
      thread.replies &amp;&amp; Array.isArray(thread.replies.comments)
        ? thread.replies.comments
        : [];

    if (state.includeReplies &amp;&amp; totalReplyCount &gt; 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 + &#39;_&#39; + nextReplyIndex;
          nextReplyIndex += 1;

          commentRows.push(
            commentToOutputRow_(state, reply, displayNumber)
          );

          internalRows.push(
            buildInternalRow_(
              replyId,
              topCommentId,
              displayNumber,
              &#39;reply&#39;,
              &#39;&#39;,
              &#39;&#39;,
              &#39;&#39;,
              &#39;&#39;
            )
          );

          batchIds.add(replyId);
          replyAdded += 1;
        });
      } else {
        // commentThreads.list의 replies에는 전체 답글이 보장되지 않으므로
        // comments.list로 전체 답글을 다시 가져옵니다.
        replyStatus = &#39;PENDING&#39;;
        nextReplyIndex = 1;
      }
    }

    internalRows.push(
      buildInternalRow_(
        topCommentId,
        &#39;&#39;,
        topNumber,
        &#39;topLevel&#39;,
        replyStatus,
        nextReplyIndex,
        totalReplyCount,
        &#39;&#39;
      )
    );
  });

  appendCollectedRecords_(
    commentsSheet,
    internalSheet,
    commentRows,
    internalRows
  );

  batchIds.forEach(function(id) {
    existingIds.add(id);
  });

  state.topCount += topAdded;
  state.replyCount += replyAdded;

  const reachedTarget = state.topCount &gt;= state.targetTopLevelCount;
  const hasNextPage = Boolean(data.nextPageToken);

  state.nextThreadPageToken =
    !reachedTarget &amp;&amp; hasNextPage
      ? data.nextPageToken
      : &#39;&#39;;

  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 = &#39;&#39;;
    state.nextReplyPageToken = &#39;&#39;;
    return;
  }

  const params = {
    part: &#39;snippet&#39;,
    parentId: state.currentReplyParentId,
    maxResults: 100,
    textFormat: &#39;plainText&#39;
  };

  const pageToken =
    state.nextReplyPageToken || topRecord.replyPageToken || &#39;&#39;;

  if (pageToken) {
    params.pageToken = pageToken;
  }

  const data = youtubeGet_(&#39;comments&#39;, 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) + &#39;_&#39; + nextReplyIndex;

    nextReplyIndex += 1;

    commentRows.push(
      commentToOutputRow_(state, reply, displayNumber)
    );

    internalRows.push(
      buildInternalRow_(
        replyId,
        state.currentReplyParentId,
        displayNumber,
        &#39;reply&#39;,
        &#39;&#39;,
        &#39;&#39;,
        &#39;&#39;,
        &#39;&#39;
      )
    );

    batchIds.add(replyId);
    replyAdded += 1;
  });

  appendCollectedRecords_(
    commentsSheet,
    internalSheet,
    commentRows,
    internalRows
  );

  batchIds.forEach(function(id) {
    existingIds.add(id);
  });

  state.replyCount += replyAdded;

  const nextPageToken = data.nextPageToken || &#39;&#39;;
  const nextStatus = nextPageToken ? &#39;IN_PROGRESS&#39; : &#39;DONE&#39;;

  updateTopReplyProgress_(
    internalSheet,
    topRecord.rowNumber,
    nextStatus,
    nextReplyIndex,
    nextPageToken
  );

  if (nextPageToken) {
    state.nextReplyPageToken = nextPageToken;
  } else {
    state.currentReplyParentId = &#39;&#39;;
    state.nextReplyPageToken = &#39;&#39;;
  }

  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(&#39;IN_PROGRESS&#39;)
    .matchEntireCell(true)
    .useRegularExpression(false)
    .findNext();

  if (!found) {
    found = statusRange
      .createTextFinder(&#39;PENDING&#39;)
      .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] === &#39;PENDING&#39;) {
    internalSheet
      .getRange(rowNumber, YT_CFG.INTERNAL_COL.REPLY_STATUS)
      .setValue(&#39;IN_PROGRESS&#39;);
  }

  return {
    rowNumber: rowNumber,
    commentId: String(values[YT_CFG.INTERNAL_COL.COMMENT_ID - 1] || &#39;&#39;),
    displayNumber: String(
      values[YT_CFG.INTERNAL_COL.DISPLAY_NUMBER - 1] || &#39;&#39;
    ),
    nextReplyIndex: Number(
      values[YT_CFG.INTERNAL_COL.NEXT_REPLY_INDEX - 1] || 1
    ),
    pageToken: String(
      values[YT_CFG.INTERNAL_COL.REPLY_PAGE_TOKEN - 1] || &#39;&#39;
    )
  };
}


/**
 * 댓글 1건을 사용자가 보는 8열 행으로 변환합니다.
 */
function commentToOutputRow_(state, comment, displayNumber) {
  const snippet = (comment &amp;&amp; comment.snippet) || {};
  const text = String(snippet.textDisplay || &#39;&#39;);

  return [
    String(displayNumber),
    safeCellText_(state.videoChannelTitle || &#39;&#39;),
    safeCellText_(state.videoTitle || &#39;&#39;),
    safeCellText_(snippet.authorDisplayName || &#39;&#39;),
    safeCellText_(text),
    Number(snippet.likeCount || 0),
    apiDateToSheetValue_(snippet.publishedAt),
    calculateCommentBytes_(text)
  ];
}


/**
 * 숨김 내부 시트의 행을 만듭니다.
 */
function buildInternalRow_(
  commentId,
  parentCommentId,
  displayNumber,
  commentType,
  replyStatus,
  nextReplyIndex,
  totalReplyCount,
  replyPageToken
) {
  return [
    safeCellText_(commentId || &#39;&#39;),
    safeCellText_(parentCommentId || &#39;&#39;),
    String(displayNumber || &#39;&#39;),
    String(commentType || &#39;&#39;),
    String(replyStatus || &#39;&#39;),
    nextReplyIndex === &#39;&#39; ? &#39;&#39; : Number(nextReplyIndex || 1),
    totalReplyCount === &#39;&#39; ? &#39;&#39; : Number(totalReplyCount || 0),
    safeCellText_(replyPageToken || &#39;&#39;)
  ];
}


/**
 * 댓글 행과 내부 관리 행을 각각 일괄 기록합니다.
 */
function appendCollectedRecords_(
  commentsSheet,
  internalSheet,
  commentRows,
  internalRows
) {
  if (commentRows.length !== internalRows.length) {
    throwStructuredError_(buildErrorInfo_(
      &#39;INTERNAL_ROW_MISMATCH&#39;,
      &#39;댓글 저장 구조를 확인해야 합니다.&#39;,
      &#39;댓글 행과 내부 관리 행의 개수가 일치하지 않습니다.&#39;,
      [&#39;수집 상태를 기록하는 내부 데이터가 일시적으로 어긋났습니다.&#39;],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;사이드바에서 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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 &lt;= 1) {
    return null;
  }

  const found = internalSheet
    .getRange(2, YT_CFG.INTERNAL_COL.COMMENT_ID, lastRow - 1, 1)
    .createTextFinder(String(commentId || &#39;&#39;))
    .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] || &#39;&#39;),
    displayNumber: String(
      values[YT_CFG.INTERNAL_COL.DISPLAY_NUMBER - 1] || &#39;&#39;
    ),
    replyStatus: String(
      values[YT_CFG.INTERNAL_COL.REPLY_STATUS - 1] || &#39;&#39;
    ),
    nextReplyIndex: Number(
      values[YT_CFG.INTERNAL_COL.NEXT_REPLY_INDEX - 1] || 1
    ),
    replyPageToken: String(
      values[YT_CFG.INTERNAL_COL.REPLY_PAGE_TOKEN - 1] || &#39;&#39;
    )
  };
}


/**
 * 최상위 댓글의 답글 수집 진행 정보를 갱신합니다.
 */
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 || &#39;&#39;
    ]]);
}


/**
 * 완료 처리
 */
function finishCollection_(ss, commentsSheet, state) {
  state.status = &#39;COMPLETED&#39;;
  state.updatedAt = new Date().toISOString();
  state.finishedAt = new Date().toISOString();
  state.message = state.topCount &lt; state.targetTopLevelCount
    ? &#39;댓글 &#39; +
      state.topCount +
      &#39;개와 대댓글 &#39; +
      state.replyCount +
      &#39;개를 수집했습니다.&#39;
    : &#39;댓글 수집을 완료했습니다.&#39;;
  state.error = null;

  finalizeCommentsSheet_(commentsSheet);
  saveLastResult_(state, &#39;COMPLETED&#39;, 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) === &#39;true&#39;;
}


/**
 * 취소 처리
 */
function finalizeCancellation_(ss, commentsSheet, state) {
  const message = &#39;사용자가 수집을 취소했습니다. 이미 저장된 댓글은 유지됩니다.&#39;;

  state.status = &#39;CANCELLED&#39;;
  state.updatedAt = new Date().toISOString();
  state.finishedAt = new Date().toISOString();
  state.message = message;
  state.error = null;

  finalizeCommentsSheet_(commentsSheet);
  saveLastResult_(state, &#39;CANCELLED&#39;, 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_(
    &#39;videos&#39;,
    {
      part: &#39;id&#39;,
      id: &#39;jNQXAC9IVRw&#39;
    },
    apiKey
  );
}


/**
 * 영상 메타데이터를 조회합니다.
 */
function getVideoMetadataWithKey_(videoId, apiKey) {
  const data = youtubeGetWithKey_(
    &#39;videos&#39;,
    {
      part: &#39;snippet,statistics&#39;,
      id: videoId
    },
    apiKey
  );

  if (!Array.isArray(data.items) || data.items.length === 0) {
    throwStructuredError_(buildErrorInfo_(
      &#39;VIDEO_NOT_FOUND&#39;,
      &#39;영상 정보를 찾을 수 없습니다.&#39;,
      &#39;입력한 영상 주소에 해당하는 공개 영상을 YouTube Data API에서 조회하지 못했습니다.&#39;,
      [
        &#39;영상 주소나 11자리 영상 ID가 정확하지 않을 수 있습니다.&#39;,
        &#39;영상이 삭제되었거나 비공개 상태일 수 있습니다.&#39;,
        &#39;연령·지역·계정 권한에 따라 접근이 제한된 영상일 수 있습니다.&#39;
      ],
      [
        &#39;브라우저에서 해당 영상 주소가 정상적으로 열리는지 확인합니다.&#39;,
        &#39;주소 표시줄의 영상 URL 전체를 다시 복사합니다.&#39;,
        &#39;사이드바의 영상 주소를 교체한 뒤 수집을 다시 시작합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: { videoId: videoId }
      }
    ));
  }

  const item = data.items[0];
  const snippet = item.snippet || {};
  const statistics = item.statistics || {};

  return {
    title: snippet.title || &#39;&#39;,
    channelId: snippet.channelId || &#39;&#39;,
    channelTitle: snippet.channelTitle || &#39;&#39;,
    commentCount: statistics.commentCount || &#39;&#39;
  };
}


/**
 * 저장된 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 &amp;&amp; value !== null &amp;&amp; value !== &#39;&#39;;
    })
    .map(function(key) {
      return encodeURIComponent(key) + &#39;=&#39; + encodeURIComponent(queryParams[key]);
    })
    .join(&#39;&amp;&#39;);

  const url = YT_CFG.API_BASE + &#39;/&#39; + resource + &#39;?&#39; + query;
  const transientCodes = [429, 500, 502, 503, 504];

  for (let attempt = 0; attempt &lt; 3; attempt += 1) {
    let response;

    try {
      response = UrlFetchApp.fetch(url, {
        method: &#39;get&#39;,
        muteHttpExceptions: true,
        headers: { Accept: &#39;application/json&#39; }
      });
    } catch (networkError) {
      if (attempt &lt; 2) {
        Utilities.sleep(Math.pow(2, attempt) * 1000);
        continue;
      }

      throwStructuredError_(buildErrorInfo_(
        &#39;NETWORK_REQUEST_FAILED&#39;,
        &#39;YouTube 서버에 연결하지 못했습니다.&#39;,
        &#39;Apps Script에서 YouTube Data API로 보내는 외부 요청이 완료되지 않았습니다.&#39;,
        [
          &#39;Google 서비스 또는 네트워크에 일시적인 장애가 발생했을 수 있습니다.&#39;,
          &#39;Apps Script의 외부 요청 권한이 승인되지 않았을 수 있습니다.&#39;
        ],
        [
          &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
          &#39;Apps Script 권한 승인 화면이 나타나면 요청된 권한을 허용합니다.&#39;,
          &#39;Apps Script의 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;
        ],
        {
          actionLabel: &#39;확인&#39;,
          technical: {
            resource: resource,
            originalMessage: String(networkError &amp;&amp; networkError.message || networkError)
          }
        }
      ));
    }

    const statusCode = response.getResponseCode();
    const responseText = response.getContentText();

    if (statusCode &gt;= 200 &amp;&amp; statusCode &lt; 300) {
      return responseText ? JSON.parse(responseText) : {};
    }

    if (transientCodes.indexOf(statusCode) &gt;= 0 &amp;&amp; attempt &lt; 2) {
      Utilities.sleep(Math.pow(2, attempt) * 1000);
      continue;
    }

    const parsedError = parseYouTubeErrorResponse_(responseText);

    throwStructuredError_(classifyYouTubeApiError_(
      statusCode,
      parsedError.reason,
      parsedError.message,
      resource
    ));
  }

  throwStructuredError_(buildErrorInfo_(
    &#39;YOUTUBE_REQUEST_REPEATED_FAILURE&#39;,
    &#39;YouTube API 요청이 반복적으로 실패했습니다.&#39;,
    &#39;일시적인 서버 장애가 재시도 후에도 계속되었습니다.&#39;,
    [&#39;YouTube 또는 Google 네트워크 서비스가 불안정할 수 있습니다.&#39;],
    [
      &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
      &#39;반복되면 Apps Script의 ‘실행’ 기록을 확인합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: { resource: resource } }
  ));
}


function parseYouTubeErrorResponse_(responseText) {
  let reason = &#39;&#39;;
  let message = String(responseText || &#39;&#39;);

  try {
    const parsed = JSON.parse(responseText);

    if (parsed.error) {
      message = parsed.error.message || message;

      if (Array.isArray(parsed.error.errors) &amp;&amp; parsed.error.errors[0]) {
        reason = parsed.error.errors[0].reason || &#39;&#39;;
      }

      if (!reason &amp;&amp; parsed.error.status) {
        reason = parsed.error.status;
      }
    }
  } catch (parseError) {
    // JSON이 아닌 오류 본문은 문자열 그대로 분류합니다.
  }

  return {
    reason: String(reason || &#39;&#39;),
    message: String(message || &#39;&#39;)
  };
}


/**
 * YouTube API 오류를 한국어 모달용 정보로 분류합니다.
 */
function classifyYouTubeApiError_(statusCode, reason, message, resource) {
  const reasonText = String(reason || &#39;&#39;);
  const messageText = String(message || &#39;&#39;);
  const combined = (reasonText + &#39; &#39; + messageText).toLowerCase();
  const technical = {
    httpStatus: statusCode,
    apiReason: reasonText,
    resource: resource,
    originalMessage: messageText
  };

  if (
    combined.indexOf(&#39;keyinvalid&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api key not valid&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api_key_invalid&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_INVALID&#39;,
      &#39;API 키를 사용할 수 없습니다.&#39;,
      &#39;입력한 API 키가 정확하지 않거나 삭제·재발급되어 현재 요청에 사용할 수 없습니다.&#39;,
      [
        &#39;API 키 일부가 누락되었을 수 있습니다.&#39;,
        &#39;Google Cloud에서 기존 키가 삭제되거나 재발급되었을 수 있습니다.&#39;,
        &#39;API 키가 아닌 다른 프로젝트 값을 입력했을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;키 유형이 ‘API 키’인지 확인합니다.&#39;,
        &#39;API 키 전체를 다시 복사합니다.&#39;,
        &#39;사이드바에서 API 키를 다시 입력하고 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;accessnotconfigured&#39;) &gt;= 0 ||
    combined.indexOf(&#39;access not configured&#39;) &gt;= 0 ||
    combined.indexOf(&#39;service_disabled&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api_disabled&#39;) &gt;= 0 ||
    combined.indexOf(&#39;has not been used in project&#39;) &gt;= 0 ||
    combined.indexOf(&#39;it is disabled&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_API_NOT_ENABLED&#39;,
      &#39;YouTube Data API v3가 활성화되어 있지 않습니다.&#39;,
      &#39;API 키가 속한 Google Cloud 프로젝트에서 YouTube Data API v3 사용 설정을 켜야 합니다.&#39;,
      [
        &#39;API 키를 만든 프로젝트에서 YouTube Data API v3를 아직 활성화하지 않았습니다.&#39;,
        &#39;API를 활성화한 프로젝트와 API 키를 발급한 프로젝트가 다를 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console에서 API 키를 만든 프로젝트를 선택합니다.&#39;,
        &#39;‘API 및 서비스 → 라이브러리’로 이동합니다.&#39;,
        &#39;‘YouTube Data API v3’를 검색하고 ‘사용’을 누릅니다.&#39;,
        &#39;설정 반영을 위해 1~5분 기다린 뒤 API 키를 다시 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;YouTube Data API 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/library/youtube.googleapis.com&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;iprefererblocked&#39;) &gt;= 0 ||
    combined.indexOf(&#39;referer&#39;) &gt;= 0 &amp;&amp; combined.indexOf(&#39;blocked&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api key restrictions&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_APPLICATION_RESTRICTION&#39;,
      &#39;API 키의 애플리케이션 제한이 Apps Script 요청을 차단했습니다.&#39;,
      &#39;현재 키에 설정된 웹사이트·IP 제한이 Apps Script의 UrlFetch 요청 방식과 맞지 않습니다.&#39;,
      [
        &#39;HTTP 리퍼러 제한 또는 특정 IP 주소 제한이 적용되어 있을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;사용 중인 API 키의 애플리케이션 제한을 확인합니다.&#39;,
        &#39;Apps Script에서 사용할 수 있는 제한 방식으로 조정합니다.&#39;,
        &#39;API 제한은 ‘YouTube Data API v3’로 유지합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 제한 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;accessnotconfigured&#39;) &lt; 0 &amp;&amp;
    (
      combined.indexOf(&#39;forbidden&#39;) &gt;= 0 &amp;&amp;
      combined.indexOf(&#39;api&#39;) &gt;= 0 &amp;&amp;
      combined.indexOf(&#39;key&#39;) &gt;= 0
    )
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_API_RESTRICTION&#39;,
      &#39;API 키의 API 제한 설정을 확인해야 합니다.&#39;,
      &#39;현재 API 키가 YouTube Data API v3 호출을 허용하지 않을 수 있습니다.&#39;,
      [
        &#39;API 키의 ‘API 제한’ 목록에서 YouTube Data API v3가 빠져 있을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;사용 중인 API 키를 선택합니다.&#39;,
        &#39;‘API 제한’에서 YouTube Data API v3를 허용합니다.&#39;,
        &#39;저장 후 1~5분 뒤 다시 실행합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 제한 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;quotaexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;dailylimitexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;daily limit&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_QUOTA_EXCEEDED&#39;,
      &#39;YouTube API의 오늘 할당량을 모두 사용했습니다.&#39;,
      &#39;현재 Google Cloud 프로젝트에서 오늘 사용할 수 있는 YouTube Data API 할당량이 남아 있지 않습니다.&#39;,
      [&#39;같은 프로젝트의 다른 프로그램에서도 YouTube API 할당량을 사용했을 수 있습니다.&#39;],
      [
        &#39;Google Cloud Console에서 YouTube Data API 사용량을 확인합니다.&#39;,
        &#39;일일 할당량이 갱신된 뒤 다시 실행합니다.&#39;,
        &#39;필요하면 다음 수집의 댓글 개수를 줄입니다.&#39;
      ],
      {
        helpLabel: &#39;YouTube API 할당량 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas&#39;,
        actionLabel: &#39;확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;ratelimitexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;userratelimitexceeded&#39;) &gt;= 0 ||
    statusCode === 429
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_RATE_LIMIT&#39;,
      &#39;짧은 시간에 API 요청이 많이 발생했습니다.&#39;,
      &#39;YouTube Data API가 일시적으로 요청 속도를 제한했습니다.&#39;,
      [&#39;여러 수집 작업을 연속으로 실행했을 수 있습니다.&#39;],
      [
        &#39;현재 작업이 자동 재개되는지 확인합니다.&#39;,
        &#39;몇 분 뒤 다시 실행합니다.&#39;,
        &#39;동시에 여러 스프레드시트에서 같은 API 키를 사용하고 있다면 실행 횟수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (combined.indexOf(&#39;commentsdisabled&#39;) &gt;= 0) {
    return buildErrorInfo_(
      &#39;COMMENTS_DISABLED&#39;,
      &#39;이 영상에서는 댓글을 수집할 수 없습니다.&#39;,
      &#39;영상 게시자가 댓글 기능을 사용하지 않도록 설정했습니다.&#39;,
      [&#39;영상의 댓글 기능이 꺼져 있거나 YouTube 정책에 따라 제한되었습니다.&#39;],
      [
        &#39;영상 페이지에서 댓글 영역이 표시되는지 확인합니다.&#39;,
        &#39;댓글이 허용된 다른 영상 주소를 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;videonotfound&#39;) &gt;= 0 ||
    combined.indexOf(&#39;video not found&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;VIDEO_NOT_FOUND&#39;,
      &#39;영상 정보를 찾을 수 없습니다.&#39;,
      &#39;영상 주소가 잘못되었거나 영상이 삭제·비공개 상태일 수 있습니다.&#39;,
      [
        &#39;영상 ID가 정확하지 않을 수 있습니다.&#39;,
        &#39;영상 공개 상태가 변경되었을 수 있습니다.&#39;
      ],
      [
        &#39;브라우저에서 영상 주소가 정상적으로 열리는지 확인합니다.&#39;,
        &#39;영상 URL 전체를 다시 복사해 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (statusCode &gt;= 500) {
    return buildErrorInfo_(
      &#39;YOUTUBE_SERVER_ERROR&#39;,
      &#39;YouTube 서버에서 일시적인 오류가 발생했습니다.&#39;,
      &#39;YouTube Data API 서버가 현재 요청을 정상적으로 처리하지 못했습니다.&#39;,
      [&#39;YouTube 측의 일시적인 서비스 장애일 수 있습니다.&#39;],
      [
        &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
        &#39;진행 중인 수집이라면 자동 재개 여부를 확인합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (statusCode === 403) {
    return buildErrorInfo_(
      &#39;YOUTUBE_ACCESS_FORBIDDEN&#39;,
      &#39;YouTube 데이터에 접근할 수 없습니다.&#39;,
      &#39;API 키 설정, 영상 공개 상태 또는 Google Cloud 프로젝트 권한을 확인해야 합니다.&#39;,
      [
        &#39;API 키 제한 설정이 요청을 차단했을 수 있습니다.&#39;,
        &#39;영상이 공개 API에서 접근할 수 없는 상태일 수 있습니다.&#39;
      ],
      [
        &#39;API 키의 API 제한과 애플리케이션 제한을 확인합니다.&#39;,
        &#39;영상이 공개 상태인지 확인합니다.&#39;,
        &#39;Google Cloud에서 YouTube Data API v3가 활성화되어 있는지 확인합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (statusCode === 400) {
    return buildErrorInfo_(
      &#39;YOUTUBE_BAD_REQUEST&#39;,
      &#39;YouTube API 요청 형식을 확인해야 합니다.&#39;,
      &#39;영상 주소 또는 요청 매개변수 중 하나를 YouTube API가 처리하지 못했습니다.&#39;,
      [&#39;영상 주소가 올바르지 않거나 API 요청 값이 허용 범위를 벗어났을 수 있습니다.&#39;],
      [
        &#39;영상 주소를 다시 확인합니다.&#39;,
        &#39;수집 개수와 정렬 기준을 확인합니다.&#39;,
        &#39;같은 문제가 계속되면 새 수집으로 다시 시작합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;입력값 확인&#39;,
        technical: technical
      }
    );
  }

  return buildErrorInfo_(
    &#39;YOUTUBE_API_ERROR&#39;,
    &#39;YouTube API 요청을 완료하지 못했습니다.&#39;,
    &#39;YouTube Data API가 요청을 거부하거나 처리하지 못했습니다.&#39;,
    [&#39;API 키, 영상 공개 상태, 할당량 또는 네트워크 상태를 확인해야 합니다.&#39;],
    [
      &#39;API 키 설정과 YouTube Data API 활성화 여부를 확인합니다.&#39;,
      &#39;영상 주소가 공개 영상인지 확인합니다.&#39;,
      &#39;잠시 뒤 다시 실행합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: technical }
  );
}


/**
 * 영상 URL 또는 11자리 영상 ID에서 videoId를 추출합니다.
 */
function extractVideoId_(input) {
  const text = String(input || &#39;&#39;).trim();

  if (/^[A-Za-z0-9_-]{11}$/.test(text)) {
    return text;
  }

  const patterns = [
    /[?&amp;]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 &lt; patterns.length; i += 1) {
    const match = text.match(patterns[i]);

    if (match) {
      return match[1];
    }
  }

  throwStructuredError_(buildErrorInfo_(
    &#39;INVALID_VIDEO_URL&#39;,
    &#39;YouTube 영상 주소를 확인해야 합니다.&#39;,
    &#39;입력한 값에서 11자리 YouTube 영상 ID를 찾지 못했습니다.&#39;,
    [
      &#39;영상 주소 일부가 빠졌을 수 있습니다.&#39;,
      &#39;채널·재생목록 주소를 입력했을 수 있습니다.&#39;
    ],
    [
      &#39;YouTube에서 수집할 영상을 엽니다.&#39;,
      &#39;브라우저 주소 표시줄의 영상 URL 전체를 복사합니다.&#39;,
      &#39;사이드바의 영상 주소 입력란에 붙여 넣습니다.&#39;
    ],
    {
      focusTarget: &#39;videoUrl&#39;,
      actionLabel: &#39;영상 주소 다시 입력&#39;
    }
  ));
}


/**
 * 사이드바 입력값을 검증합니다.
 */
function normalizeSidebarInput_(formData) {
  const form = formData || {};
  const videoUrl = String(form.videoUrl || &#39;&#39;).trim();
  const limit = parseInt(form.limit, 10);

  if (!videoUrl) {
    throwStructuredError_(buildErrorInfo_(
      &#39;VIDEO_URL_REQUIRED&#39;,
      &#39;YouTube 영상 주소를 입력해야 합니다.&#39;,
      &#39;수집 대상 영상 주소가 비어 있습니다.&#39;,
      [&#39;영상 주소 입력란에 값이 없습니다.&#39;],
      [&#39;수집할 YouTube 영상의 URL을 입력합니다.&#39;],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 입력&#39;
      }
    ));
  }

  if (!Number.isInteger(limit)) {
    throwStructuredError_(buildErrorInfo_(
      &#39;COMMENT_LIMIT_INVALID&#39;,
      &#39;수집 개수를 정수로 입력해야 합니다.&#39;,
      &#39;수집할 최상위 댓글 개수가 올바른 정수 형식이 아닙니다.&#39;,
      [&#39;빈 값, 소수 또는 문자가 입력되었을 수 있습니다.&#39;],
      [&#39;1 이상의 정수를 입력합니다.&#39;],
      {
        focusTarget: &#39;limit&#39;,
        actionLabel: &#39;수집 개수 입력&#39;
      }
    ));
  }

  if (limit &lt; YT_CFG.MIN_LIMIT || limit &gt; YT_CFG.MAX_LIMIT) {
    throwStructuredError_(buildErrorInfo_(
      &#39;COMMENT_LIMIT_OUT_OF_RANGE&#39;,
      &#39;수집 개수가 허용 범위를 벗어났습니다.&#39;,
      &#39;수집 개수는 &#39; + YT_CFG.MIN_LIMIT + &#39;개 이상 &#39; +
        YT_CFG.MAX_LIMIT + &#39;개 이하로 입력해야 합니다.&#39;,
      [&#39;입력한 개수가 코드에 설정된 운영 범위를 벗어났습니다.&#39;],
      [
        YT_CFG.MIN_LIMIT + &#39;~&#39; + YT_CFG.MAX_LIMIT +
          &#39; 사이의 정수를 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;limit&#39;,
        actionLabel: &#39;수집 개수 수정&#39;
      }
    ));
  }

  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 ... 순서로 만듭니다.
 *
 * 새 스프레드시트에 기본 빈 &#39;시트1&#39; 하나만 존재하는 경우에는 그 시트를
 * 첫 결과 시트로 사용합니다. 그 이후 수집부터는 항상 새 시트를 추가합니다.
 */
function createNextOutputSheet_(ss) {
  const sheets = ss.getSheets();

  if (
    sheets.length === 1 &amp;&amp;
    sheets[0].getName() === YT_CFG.OUTPUT_SHEET_PREFIX + &#39;1&#39; &amp;&amp;
    isSheetCompletelyBlank_(sheets[0])
  ) {
    prepareOutputSheet_(sheets[0]);
    return sheets[0];
  }

  let maxNumber = 0;
  const namePattern = new RegExp(
    &#39;^&#39; + escapeRegExp_(YT_CFG.OUTPUT_SHEET_PREFIX) + &#39;(\\d+)$&#39;
  );

  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 &amp;&amp; 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(&#39;A:A&#39;).setNumberFormat(&#39;@&#39;);
  formatCommentsSheet_(sheet);
}


/**
 * 새 수집을 위해 숨김 내부 시트만 초기화합니다.
 * 이전 결과 시트의 내용은 변경하지 않습니다.
 */
function resetInternalData_(ss) {
  const internal = ss.getSheetByName(YT_CFG.INTERNAL_SHEET);

  if (!internal) {
    throwStructuredError_(buildErrorInfo_(
      &#39;INTERNAL_SHEET_NOT_FOUND&#39;,
      &#39;내부 관리 시트를 준비하지 못했습니다.&#39;,
      &#39;댓글 수집 상태를 저장하는 숨김 시트가 존재하지 않습니다.&#39;,
      [&#39;내부 시트 생성 과정이 완료되지 않았습니다.&#39;],
      [&#39;사이드바를 닫았다가 다시 열고 새 수집을 시작합니다.&#39;],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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 &gt; 0) {
    outputSheet = ss.getSheets().find(function(sheet) {
      return sheet.getSheetId() === expectedId;
    }) || null;
  }

  if (!outputSheet &amp;&amp; state.outputSheetName) {
    outputSheet = ss.getSheetByName(String(state.outputSheetName));
  }

  if (!outputSheet) {
    throwStructuredError_(buildErrorInfo_(
      &#39;OUTPUT_SHEET_NOT_FOUND&#39;,
      &#39;수집 결과 시트를 찾을 수 없습니다.&#39;,
      &#39;현재 수집 작업과 연결된 결과 시트가 삭제되었거나 이름과 식별 정보가 변경되었습니다.&#39;,
      [
        &#39;수집 중 결과 시트를 삭제했을 수 있습니다.&#39;,
        &#39;스프레드시트 구조가 외부 작업으로 변경되었을 수 있습니다.&#39;
      ],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;사이드바에서 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;새 수집 필요&#39; }
    ));
  }

  return outputSheet;
}


function escapeRegExp_(text) {
  return String(text).replace(/[.*+?^${}()|[\]\\]/g, &#39;\\$&amp;&#39;);
}


/**
 * 이전 버전이 만든 수집로그 시트를 제거합니다.
 */
function deleteLegacyLogSheet_(ss) {
  const logSheet = ss.getSheetByName(YT_CFG.LEGACY_LOG_SHEET);

  if (logSheet &amp;&amp; ss.getSheets().length &gt; 1) {
    ss.deleteSheet(logSheet);
  }
}


/**
 * 댓글 시트를 번호 순으로 정렬하고 최종 서식을 적용합니다.
 */
function finalizeCommentsSheet_(sheet) {
  sortCommentsByDisplayNumber_(sheet);
  formatCommentsSheet_(sheet);
}


function sortCommentsByDisplayNumber_(sheet) {
  const lastRow = sheet.getLastRow();

  if (lastRow &lt;= 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] || &#39;&#39;).split(&#39;_&#39;);
    return [
      Number(parts[0] || 0),
      parts.length &gt; 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(&#39;bold&#39;)
    .setWrap(true)
    .setVerticalAlignment(&#39;middle&#39;);

  sheet.getRange(&#39;A:A&#39;).setNumberFormat(&#39;@&#39;);

  if (lastRow &gt; 1) {
    sheet
      .getRange(2, 7, lastRow - 1, 1)
      .setNumberFormat(&#39;yyyy-mm-dd hh:mm:ss&#39;);

    sheet
      .getRange(2, 5, lastRow - 1, 1)
      .setWrap(true)
      .setVerticalAlignment(&#39;top&#39;);

    sheet
      .getRange(2, 1, lastRow - 1, YT_CFG.HEADERS.length)
      .setVerticalAlignment(&#39;top&#39;);

    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 &lt;= 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 &lt;= 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] === &#39;topLevel&#39;) {
      counts.top += 1;
    } else if (row[0] === &#39;reply&#39;) {
      counts.reply += 1;
    }
  });

  return counts;
}


/**
 * 댓글 글자수를 지정된 바이트 규칙으로 계산합니다.
 * - ASCII 영역(영문, 숫자, 공백, 일반 문장부호): 1바이트
 * - 한글을 포함한 비ASCII 유니코드 문자: 코드 포인트당 2바이트
 */
function calculateCommentBytes_(value) {
  const text = String(value || &#39;&#39;);
  let bytes = 0;

  Array.from(text).forEach(function(character) {
    const codePoint = character.codePointAt(0);
    bytes += codePoint &lt;= 0x7F ? 1 : 2;
  });

  return bytes;
}


/**
 * 외부 문자열이 시트 수식으로 실행되는 것을 방지합니다.
 */
function safeCellText_(value) {
  const text = value === undefined || value === null ? &#39;&#39; : String(value);
  return /^[=+\-@]/.test(text) ? &quot;&#39;&quot; + text : text;
}


function apiDateToSheetValue_(value) {
  return value ? new Date(value) : &#39;&#39;;
}


function ensureColumnCount_(sheet, requiredColumns) {
  const current = sheet.getMaxColumns();

  if (current &lt; requiredColumns) {
    sheet.insertColumnsAfter(current, requiredColumns - current);
  }
}


function ensureExactColumnCount_(sheet, requiredColumns) {
  ensureColumnCount_(sheet, requiredColumns);

  const current = sheet.getMaxColumns();

  if (current &gt; 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_(
      &#39;API_KEY_REQUIRED&#39;,
      &#39;API 키 설정이 필요합니다.&#39;,
      &#39;댓글 수집을 시작하기 전에 YouTube Data API 키를 저장해야 합니다.&#39;,
      [&#39;현재 사용자 계정에 저장된 API 키가 없습니다.&#39;],
      [
        &#39;사이드바 상단의 API 키 입력란에 키를 입력합니다.&#39;,
        &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
      ],
      {
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 입력&#39;
      }
    ));
  }

  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 &gt; 8500) {
    throwStructuredError_(buildErrorInfo_(
      &#39;STATE_STORAGE_LIMIT&#39;,
      &#39;수집 진행 상태를 저장할 수 없습니다.&#39;,
      &#39;Apps Script 사용자 속성의 안전 저장 크기를 초과했습니다.&#39;,
      [&#39;진행 상태 데이터가 예상보다 커졌습니다.&#39;],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;댓글 수집 개수를 줄여 새로 실행합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, 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 || &#39;&#39;,
    videoId: state.videoId || &#39;&#39;,
    videoTitle: state.videoTitle || &#39;&#39;,
    targetTopLevelCount: Number(state.targetTopLevelCount || 0),
    topCount: Number(state.topCount || 0),
    replyCount: Number(state.replyCount || 0),
    apiCalls: Number(state.apiCalls || 0),
    startedAt: state.startedAt || &#39;&#39;,
    updatedAt: new Date().toISOString(),
    finishedAt: new Date().toISOString(),
    message: message || &#39;&#39;,
    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_(
      &#39;STATE_VERSION_MISMATCH&#39;,
      &#39;이전 코드에서 시작된 수집 작업을 이어갈 수 없습니다.&#39;,
      &#39;현재 Code.gs의 댓글 저장 구조가 이전 실행 상태와 다릅니다.&#39;,
      [&#39;고정 결과 시트 또는 정렬 기준을 사용하던 이전 코드의 작업 상태가 남아 있습니다.&#39;],
      [
        &#39;사이드바에서 진행 중인 수집을 취소합니다.&#39;,
        &#39;현재 코드로 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;새 수집 필요&#39; }
    ));
  }
}


function normalizeState_(state) {
  state.currentReplyParentId = state.currentReplyParentId || &#39;&#39;;
  state.nextReplyPageToken = state.nextReplyPageToken || &#39;&#39;;
  state.nextThreadPageToken = state.nextThreadPageToken || &#39;&#39;;
  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 || &#39;&#39;);
  state.error = state.error || null;
}


function maskApiKey_(apiKey) {
  const key = String(apiKey || &#39;&#39;);
  return &#39;••••••••••••&#39; + 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 &gt; 0
    ? Math.min(100, Math.round((top / target) * 100))
    : 0;

  let stage = &#39;대기 중&#39;;

  if (status === &#39;RUNNING&#39;) {
    if (state.currentReplyParentId) {
      stage = &#39;답글 수집 중&#39;;
    } else if (!state.threadDone) {
      stage = &#39;최상위 댓글 수집 중&#39;;
    } else {
      stage = &#39;마무리 중&#39;;
    }
  } else if (status === &#39;COMPLETED&#39;) {
    stage = &#39;완료&#39;;
  } else if (status === &#39;ERROR&#39;) {
    stage = &#39;오류&#39;;
  } else if (status === &#39;CANCELLED&#39;) {
    stage = &#39;취소됨&#39;;
  }

  return {
    status: status,
    stage: stage,
    outputSheetId: Number(state.outputSheetId || 0),
    outputSheetName: state.outputSheetName || &#39;&#39;,
    videoId: state.videoId || &#39;&#39;,
    videoTitle: state.videoTitle || &#39;&#39;,
    targetTopLevelCount: target,
    topCount: top,
    replyCount: reply,
    totalRows: top + reply,
    apiCalls: Number(state.apiCalls || 0),
    percent: percent,
    startedAt: state.startedAt || &#39;&#39;,
    updatedAt: state.updatedAt || &#39;&#39;,
    message: state.message || &#39;&#39;,
    error: state.error || null
  };
}


function buildServerProgressMessage_(state) {
  return (
    &#39;최상위 댓글 &#39; +
    state.topCount +
    &#39;/&#39; +
    state.targetTopLevelCount +
    &#39;개, 답글 &#39; +
    state.replyCount +
    &#39;개를 저장했습니다.&#39;
  );
}


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 || &#39;UNKNOWN_ERROR&#39;),
    title: String(title || &#39;작업을 완료하지 못했습니다.&#39;),
    summary: String(summary || &#39;처리 중 문제가 발생했습니다.&#39;),
    causes: Array.isArray(causes) ? causes.map(String) : [],
    steps: Array.isArray(steps) ? steps.map(String) : [],
    helpLabel: String(opts.helpLabel || &#39;&#39;),
    helpUrl: String(opts.helpUrl || &#39;&#39;),
    focusTarget: String(opts.focusTarget || &#39;&#39;),
    actionLabel: String(opts.actionLabel || &#39;확인&#39;),
    technical:
      opts.technical &amp;&amp; typeof opts.technical === &#39;object&#39;
        ? 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 || &#39;&#39;);
  const markerIndex = raw.indexOf(YT_CFG.STRUCTURED_ERROR_PREFIX);

  if (markerIndex &lt; 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 &amp;&amp; error.message
    ? String(error.message)
    : String(error || &#39;&#39;);

  const structured = extractStructuredError_(raw);

  if (structured) {
    return structured;
  }

  const lower = raw.toLowerCase();
  const technical = {
    context: String(context || &#39;작업 실행&#39;),
    originalMessage: raw
  };

  if (
    lower.indexOf(&#39;authorization is required&#39;) &gt;= 0 ||
    lower.indexOf(&#39;you do not have permission&#39;) &gt;= 0 ||
    lower.indexOf(&#39;permissions&#39;) &gt;= 0 &amp;&amp; lower.indexOf(&#39;required&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;,
      &#39;Google 권한 승인이 필요합니다.&#39;,
      &#39;현재 계정이 스프레드시트 또는 외부 API 요청 권한을 아직 승인하지 않았습니다.&#39;,
      [&#39;Apps Script를 처음 실행했거나 새로운 권한이 추가되었습니다.&#39;],
      [
        &#39;스프레드시트 메뉴에서 사이드바를 다시 엽니다.&#39;,
        &#39;권한 검토 화면에서 현재 Google 계정을 선택합니다.&#39;,
        &#39;요청되는 권한을 허용한 뒤 다시 실행합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (
    lower.indexOf(&#39;maximum execution time&#39;) &gt;= 0 ||
    lower.indexOf(&#39;exceeded maximum execution time&#39;) &gt;= 0 ||
    lower.indexOf(&#39;timed out&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;,
      &#39;한 번의 실행 가능 시간을 초과했습니다.&#39;,
      &#39;댓글 또는 답글 처리 시간이 길어 현재 실행 구간이 종료되었습니다.&#39;,
      [&#39;대량 댓글 수집으로 한 번의 Apps Script 실행 시간이 길어졌습니다.&#39;],
      [
        &#39;사이드바를 다시 열어 진행 상태를 확인합니다.&#39;,
        &#39;멈춘 경우 ‘중단 지점부터 실행’을 한 번 실행합니다.&#39;,
        &#39;같은 문제가 반복되면 수집 개수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (
    lower.indexOf(&#39;service invoked too many times&#39;) &gt;= 0 ||
    lower.indexOf(&#39;quota&#39;) &gt;= 0 &amp;&amp; lower.indexOf(&#39;apps script&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_SERVICE_QUOTA&#39;,
      &#39;Apps Script 서비스 사용 한도를 초과했습니다.&#39;,
      &#39;현재 Google 계정 또는 스크립트의 일일 서비스 사용량이 허용 범위를 넘었습니다.&#39;,
      [&#39;짧은 시간에 많은 외부 요청이나 스프레드시트 쓰기 작업이 실행되었습니다.&#39;],
      [
        &#39;잠시 뒤 다시 실행합니다.&#39;,
        &#39;같은 날 반복되면 다음 날 다시 실행합니다.&#39;,
        &#39;한 번에 수집하는 댓글 개수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  return buildErrorInfo_(
    &#39;UNEXPECTED_ERROR&#39;,
    &#39;예상하지 못한 오류가 발생했습니다.&#39;,
    &#39;오류 유형을 자동으로 판별하지 못했습니다.&#39;,
    [
      &#39;네트워크, 권한, Google 서비스 상태 또는 실행 환경에서 문제가 발생했을 수 있습니다.&#39;
    ],
    [
      &#39;사이드바를 닫았다가 다시 열고 같은 작업을 한 번 더 실행합니다.&#39;,
      &#39;Apps Script 편집기의 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;,
      &#39;API 키와 영상 주소를 다시 확인합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: technical }
  );
}
</pre></section><section><h3>Sidebar.html</h3><pre>&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;meta charset=&quot;utf-8&quot;&gt;
    &lt;style&gt;
      :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, &#39;Noto Sans KR&#39;, 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=&#39;text&#39;],
      input[type=&#39;password&#39;],
      input[type=&#39;number&#39;] {
        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);
        }
      }
    &lt;/style&gt;
  &lt;/head&gt;

  &lt;body&gt;
    &lt;h1&gt;YouTube 댓글 수집기&lt;/h1&gt;
    &lt;p class=&quot;subtitle&quot;&gt;YouTube Data API v3 → Google Sheets&lt;/p&gt;

    &lt;div id=&quot;message&quot; class=&quot;message&quot;&gt;&lt;/div&gt;

    &lt;section class=&quot;card&quot;&gt;
      &lt;h2&gt;1. API 키&lt;/h2&gt;

      &lt;div id=&quot;apiMissingView&quot;&gt;
        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;apiKey&quot;&gt;YouTube Data API 키&lt;/label&gt;
          &lt;input
            id=&quot;apiKey&quot;
            type=&quot;password&quot;
            autocomplete=&quot;off&quot;
            placeholder=&quot;AIza...&quot;
          &gt;
          &lt;div class=&quot;help&quot;&gt;
            최초 1회 저장합니다. 키는 사용자별 Apps Script 속성에 보관됩니다.
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;button id=&quot;saveApiButton&quot; class=&quot;primary&quot; type=&quot;button&quot;&gt;
          API 키 확인 후 저장
        &lt;/button&gt;
      &lt;/div&gt;

      &lt;div id=&quot;apiSavedView&quot; class=&quot;hidden&quot;&gt;
        &lt;div class=&quot;row between&quot;&gt;
          &lt;div class=&quot;status-line&quot;&gt;
            &lt;span class=&quot;badge success&quot;&gt;설정 완료&lt;/span&gt;
            &lt;span id=&quot;apiKeyMask&quot; class=&quot;masked-key&quot;&gt;&lt;/span&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;div class=&quot;row&quot; style=&quot;margin-top: 10px;&quot;&gt;
          &lt;button id=&quot;changeApiButton&quot; class=&quot;secondary small-button&quot; type=&quot;button&quot;&gt;
            키 변경
          &lt;/button&gt;
          &lt;button id=&quot;deleteApiButton&quot; class=&quot;danger small-button&quot; type=&quot;button&quot;&gt;
            키 삭제
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/section&gt;

    &lt;section class=&quot;card soft&quot;&gt;
      &lt;h2&gt;2. 수집 설정&lt;/h2&gt;

      &lt;div id=&quot;lockedMessage&quot; class=&quot;locked-message&quot;&gt;
        API 키를 저장하면 수집 설정이 활성화됩니다.
      &lt;/div&gt;

      &lt;fieldset id=&quot;collectorFieldset&quot; disabled&gt;
        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;videoUrl&quot;&gt;YouTube 영상 주소&lt;/label&gt;
          &lt;input
            id=&quot;videoUrl&quot;
            type=&quot;text&quot;
            placeholder=&quot;https://www.youtube.com/watch?v=...&quot;
          &gt;
        &lt;/div&gt;

        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;limit&quot;&gt;수집할 최상위 댓글 수&lt;/label&gt;
          &lt;input
            id=&quot;limit&quot;
            type=&quot;number&quot;
            min=&quot;1&quot;
            max=&quot;50000&quot;
            step=&quot;1&quot;
            value=&quot;100&quot;
          &gt;
          &lt;div class=&quot;help&quot;&gt;
            1~50,000개. 답글 포함 시 실제 저장 행 수가 이 값보다 많아질 수 있습니다.
          &lt;/div&gt;
        &lt;/div&gt;


        &lt;div class=&quot;field check-row&quot;&gt;
          &lt;input id=&quot;includeReplies&quot; type=&quot;checkbox&quot; checked&gt;
          &lt;label for=&quot;includeReplies&quot;&gt;
            대댓글도 모두 수집
          &lt;/label&gt;
        &lt;/div&gt;

        &lt;div class=&quot;help&quot; style=&quot;margin-bottom: 10px;&quot;&gt;
          새 수집을 시작할 때마다 ‘시트1’, ‘시트2’, ‘시트3’ 형식의 새 시트가 생성됩니다. 기존 수집 결과는 유지됩니다.
        &lt;/div&gt;

        &lt;button id=&quot;startButton&quot; class=&quot;primary&quot; type=&quot;button&quot;&gt;
          수집 시작
        &lt;/button&gt;
      &lt;/fieldset&gt;
    &lt;/section&gt;

    &lt;section id=&quot;progressCard&quot; class=&quot;card hidden&quot;&gt;
      &lt;div class=&quot;row between&quot;&gt;
        &lt;h2 style=&quot;margin: 0;&quot;&gt;진행 상태&lt;/h2&gt;
        &lt;span id=&quot;progressBadge&quot; class=&quot;badge warning&quot;&gt;대기 중&lt;/span&gt;
      &lt;/div&gt;

      &lt;div id=&quot;progressTitle&quot; class=&quot;progress-title&quot; style=&quot;margin-top: 10px;&quot;&gt;&lt;/div&gt;

      &lt;div class=&quot;progress-bar&quot;&gt;
        &lt;div id=&quot;progressFill&quot; class=&quot;progress-fill&quot;&gt;&lt;/div&gt;
      &lt;/div&gt;

      &lt;div class=&quot;metric-grid&quot;&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;댓글&lt;/div&gt;
          &lt;div id=&quot;topMetric&quot; class=&quot;metric-value&quot;&gt;0 / 0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;대댓글&lt;/div&gt;
          &lt;div id=&quot;replyMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;전체 저장 행&lt;/div&gt;
          &lt;div id=&quot;totalMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;API 호출 횟수&lt;/div&gt;
          &lt;div id=&quot;apiCallMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;p id=&quot;progressMessage&quot; class=&quot;help&quot;&gt;&lt;/p&gt;

      &lt;button id=&quot;cancelButton&quot; class=&quot;danger hidden&quot; type=&quot;button&quot; style=&quot;width: 100%; margin-top: 12px;&quot;&gt;
        진행 중인 수집 취소
      &lt;/button&gt;
    &lt;/section&gt;


    &lt;div
      id=&quot;errorModalBackdrop&quot;
      class=&quot;modal-backdrop hidden&quot;
      role=&quot;dialog&quot;
      aria-modal=&quot;true&quot;
      aria-labelledby=&quot;errorModalTitle&quot;
    &gt;
      &lt;div class=&quot;modal-panel&quot;&gt;
        &lt;div class=&quot;modal-header&quot;&gt;
          &lt;div class=&quot;modal-icon&quot; aria-hidden=&quot;true&quot;&gt;!&lt;/div&gt;

          &lt;div class=&quot;modal-heading&quot;&gt;
            &lt;h2 id=&quot;errorModalTitle&quot; class=&quot;modal-title&quot;&gt;
              문제를 확인해야 합니다.
            &lt;/h2&gt;
            &lt;div id=&quot;errorModalCode&quot; class=&quot;modal-code&quot;&gt;&lt;/div&gt;
          &lt;/div&gt;

          &lt;button
            id=&quot;errorModalCloseButton&quot;
            class=&quot;modal-close&quot;
            type=&quot;button&quot;
            aria-label=&quot;오류 안내 닫기&quot;
          &gt;
            ×
          &lt;/button&gt;
        &lt;/div&gt;

        &lt;div class=&quot;modal-body&quot;&gt;
          &lt;div id=&quot;errorModalSummary&quot; class=&quot;modal-summary&quot;&gt;&lt;/div&gt;

          &lt;section id=&quot;errorCauseSection&quot; class=&quot;modal-section&quot;&gt;
            &lt;h3&gt;발생 가능한 원인&lt;/h3&gt;
            &lt;ul id=&quot;errorCauseList&quot;&gt;&lt;/ul&gt;
          &lt;/section&gt;

          &lt;section id=&quot;errorStepSection&quot; class=&quot;modal-section&quot;&gt;
            &lt;h3&gt;해결 방법&lt;/h3&gt;
            &lt;ol id=&quot;errorStepList&quot;&gt;&lt;/ol&gt;
          &lt;/section&gt;

          &lt;details id=&quot;errorTechnicalDetails&quot; class=&quot;technical-details&quot;&gt;
            &lt;summary&gt;기술 정보 보기&lt;/summary&gt;
            &lt;div id=&quot;errorTechnicalGrid&quot; class=&quot;technical-grid&quot;&gt;&lt;/div&gt;
          &lt;/details&gt;
        &lt;/div&gt;

        &lt;div class=&quot;modal-actions&quot;&gt;
          &lt;a
            id=&quot;errorHelpLink&quot;
            class=&quot;modal-help-link hidden&quot;
            href=&quot;#&quot;
            target=&quot;_blank&quot;
            rel=&quot;noopener noreferrer&quot;
          &gt;&lt;/a&gt;

          &lt;button
            id=&quot;errorModalPrimaryButton&quot;
            class=&quot;primary&quot;
            type=&quot;button&quot;
          &gt;
            확인
          &lt;/button&gt;

          &lt;button
            id=&quot;errorModalDismissButton&quot;
            class=&quot;secondary&quot;
            type=&quot;button&quot;
          &gt;
            닫기
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;script&gt;
      const ERROR_PREFIX = &#39;YT_STRUCTURED_ERROR::&#39;;

      let currentState = null;
      let pollTimer = null;
      let pollInFlight = false;
      let lastShownErrorKey = &#39;&#39;;
      let pendingFocusTarget = &#39;&#39;;

      document.addEventListener(&#39;DOMContentLoaded&#39;, function() {
        bindEvents();
        refreshState(false);
      });

      function bindEvents() {
        document.getElementById(&#39;saveApiButton&#39;).addEventListener(&#39;click&#39;, saveApiKey);
        document.getElementById(&#39;changeApiButton&#39;).addEventListener(&#39;click&#39;, showApiKeyEditor);
        document.getElementById(&#39;deleteApiButton&#39;).addEventListener(&#39;click&#39;, deleteApiKey);
        document.getElementById(&#39;startButton&#39;).addEventListener(&#39;click&#39;, startCollection);
        document.getElementById(&#39;cancelButton&#39;).addEventListener(&#39;click&#39;, cancelCollection);

        document
          .getElementById(&#39;errorModalCloseButton&#39;)
          .addEventListener(&#39;click&#39;, closeErrorModal);

        document
          .getElementById(&#39;errorModalDismissButton&#39;)
          .addEventListener(&#39;click&#39;, closeErrorModal);

        document
          .getElementById(&#39;errorModalPrimaryButton&#39;)
          .addEventListener(&#39;click&#39;, handleErrorModalPrimaryAction);

        document
          .getElementById(&#39;errorModalBackdrop&#39;)
          .addEventListener(&#39;click&#39;, function(event) {
            if (event.target === event.currentTarget) {
              closeErrorModal();
            }
          });

        document.addEventListener(&#39;keydown&#39;, function(event) {
          if (event.key === &#39;Escape&#39;) {
            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, &#39;상태 확인&#39;);
            }
          })
          .getSidebarState();
      }

      function renderState(state) {
        currentState = state || {};

        const configured = Boolean(currentState.apiKeyConfigured);
        const running = Boolean(currentState.running);

        document.getElementById(&#39;apiMissingView&#39;).classList.toggle(&#39;hidden&#39;, configured);
        document.getElementById(&#39;apiSavedView&#39;).classList.toggle(&#39;hidden&#39;, !configured);
        document.getElementById(&#39;apiKeyMask&#39;).textContent = currentState.apiKeyMask || &#39;&#39;;

        document.getElementById(&#39;collectorFieldset&#39;).disabled = !configured || running;
        document.getElementById(&#39;lockedMessage&#39;).classList.toggle(&#39;hidden&#39;, configured);

        document.getElementById(&#39;changeApiButton&#39;).disabled = running;
        document.getElementById(&#39;deleteApiButton&#39;).disabled = running;

        renderProgress(currentState.progress, running);

        if (running) {
          startPolling();
        } else {
          stopPolling();
        }
      }

      function renderProgress(progress, running) {
        const card = document.getElementById(&#39;progressCard&#39;);

        if (!progress) {
          card.classList.add(&#39;hidden&#39;);
          return;
        }

        card.classList.remove(&#39;hidden&#39;);

        const status = progress.status || &#39;&#39;;
        const badge = document.getElementById(&#39;progressBadge&#39;);
        badge.textContent = progress.stage || status;
        badge.className = &#39;badge &#39; + getBadgeClass(status);

        const progressTitle =
          progress.videoTitle || progress.videoId || &#39;YouTube 영상&#39;;
        const outputSheetName = progress.outputSheetName || &#39;&#39;;

        document.getElementById(&#39;progressTitle&#39;).textContent =
          outputSheetName
            ? progressTitle + &#39; · &#39; + outputSheetName
            : progressTitle;

        document.getElementById(&#39;progressFill&#39;).style.width =
          String(progress.percent || 0) + &#39;%&#39;;

        document.getElementById(&#39;topMetric&#39;).textContent =
          formatNumber(progress.topCount) + &#39; / &#39; + formatNumber(progress.targetTopLevelCount);

        document.getElementById(&#39;replyMetric&#39;).textContent = formatNumber(progress.replyCount);
        document.getElementById(&#39;totalMetric&#39;).textContent = formatNumber(progress.totalRows);
        document.getElementById(&#39;apiCallMetric&#39;).textContent = formatNumber(progress.apiCalls);
        document.getElementById(&#39;progressMessage&#39;).textContent = progress.message || &#39;&#39;;
        document.getElementById(&#39;cancelButton&#39;).classList.toggle(&#39;hidden&#39;, !running);

        if (status === &#39;ERROR&#39;) {
          const errorInfo = progress.error || buildFallbackErrorInfo(
            progress.message || &#39;&#39;,
            &#39;댓글 수집&#39;
          );

          const errorKey = [
            errorInfo.code || &#39;UNKNOWN_ERROR&#39;,
            progress.updatedAt || &#39;&#39;,
            progress.videoId || &#39;&#39;
          ].join(&#39;|&#39;);

          if (errorKey !== lastShownErrorKey) {
            lastShownErrorKey = errorKey;
            showErrorModal(errorInfo);
          }
        }
      }

      function saveApiKey() {
        const input = document.getElementById(&#39;apiKey&#39;);
        const button = document.getElementById(&#39;saveApiButton&#39;);
        const apiKey = input.value.trim();

        if (!apiKey) {
          showErrorModal({
            code: &#39;API_KEY_REQUIRED&#39;,
            title: &#39;API 키를 입력해야 합니다.&#39;,
            summary: &#39;API 키 입력란이 비어 있어 검증을 시작할 수 없습니다.&#39;,
            causes: [
              &#39;입력란에 값이 입력되지 않았습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’에서 API 키를 확인합니다.&#39;,
              &#39;키 값을 복사하여 입력란에 붙여 넣습니다.&#39;,
              &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
            ],
            helpLabel: &#39;API 키 관리 화면 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;API 키 입력&#39;
          });
          return;
        }

        setButtonBusy(button, true, &#39;확인 중&#39;);
        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            input.value = &#39;&#39;;
            setButtonBusy(button, false, &#39;API 키 확인 후 저장&#39;);
            renderState(state);
            showMessage(&#39;API 키를 검증하여 저장했습니다.&#39;, &#39;success&#39;);
          })
          .withFailureHandler(function(error) {
            setButtonBusy(button, false, &#39;API 키 확인 후 저장&#39;);
            handleServerError(error, &#39;API 키 검증&#39;);
          })
          .saveApiKey(apiKey);
      }

      function showApiKeyEditor() {
        document.getElementById(&#39;apiSavedView&#39;).classList.add(&#39;hidden&#39;);
        document.getElementById(&#39;apiMissingView&#39;).classList.remove(&#39;hidden&#39;);
        document.getElementById(&#39;apiKey&#39;).focus();
        showMessage(&#39;새 API 키를 입력하면 기존 키가 교체됩니다.&#39;, &#39;info&#39;);
      }

      function deleteApiKey() {
        if (!window.confirm(&#39;저장된 API 키를 삭제하시겠습니까?&#39;)) {
          return;
        }

        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            renderState(state);
            showMessage(&#39;저장된 API 키를 삭제했습니다.&#39;, &#39;success&#39;);
          })
          .withFailureHandler(function(error) {
            handleServerError(error, &#39;API 키 삭제&#39;);
          })
          .deleteApiKey();
      }

      function startCollection() {
        const videoUrl = document.getElementById(&#39;videoUrl&#39;).value.trim();
        const limitText = document.getElementById(&#39;limit&#39;).value;
        const limit = Number(limitText);
        const startButton = document.getElementById(&#39;startButton&#39;);

        if (!videoUrl) {
          showErrorModal({
            code: &#39;VIDEO_URL_REQUIRED&#39;,
            title: &#39;영상 주소를 입력해야 합니다.&#39;,
            summary: &#39;수집 대상 YouTube 영상 주소가 비어 있습니다.&#39;,
            causes: [
              &#39;영상 주소 입력란에 값이 없습니다.&#39;
            ],
            steps: [
              &#39;YouTube에서 수집할 영상을 엽니다.&#39;,
              &#39;주소창의 전체 영상 주소를 복사하여 입력합니다.&#39;
            ],
            focusTarget: &#39;videoUrl&#39;,
            actionLabel: &#39;영상 주소 입력&#39;
          });
          return;
        }

        if (!Number.isInteger(limit) || limit &lt; 1 || limit &gt; 50000) {
          showErrorModal({
            code: &#39;COMMENT_LIMIT_INVALID&#39;,
            title: &#39;수집 개수를 확인해야 합니다.&#39;,
            summary: &#39;수집 개수는 1~50,000 사이의 정수로 입력해야 합니다.&#39;,
            causes: [
              &#39;빈 값, 소수, 문자 또는 허용 범위를 벗어난 수가 입력되었습니다.&#39;
            ],
            steps: [
              &#39;수집 개수 입력란에 1~50,000 사이의 정수를 입력합니다.&#39;,
              &#39;예: 100, 500, 1000&#39;
            ],
            focusTarget: &#39;limit&#39;,
            actionLabel: &#39;수집 개수 수정&#39;
          });
          return;
        }

        const payload = {
          videoUrl: videoUrl,
          limit: limit,
          includeReplies: document.getElementById(&#39;includeReplies&#39;).checked
        };

        setButtonBusy(startButton, true, &#39;준비 중&#39;);
        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            setButtonBusy(startButton, false, &#39;수집 시작&#39;);
            renderState(state);
            const sheetName =
              state &amp;&amp;
              state.progress &amp;&amp;
              state.progress.outputSheetName
                ? state.progress.outputSheetName
                : &#39;&#39;;

            showMessage(
              sheetName
                ? sheetName + &#39;에서 수집 작업을 시작했습니다.&#39;
                : &#39;수집 작업을 시작했습니다.&#39;,
              &#39;info&#39;
            );
            startPolling();

            // 서버에서 첫 수집 구간을 즉시 실행합니다.
            google.script.run
              .withSuccessHandler(function(updatedState) {
                renderState(updatedState);
              })
              .withFailureHandler(function(error) {
                refreshState(true);
                handleServerError(error, &#39;댓글 수집&#39;);
              })
              .runCollectionNow();
          })
          .withFailureHandler(function(error) {
            setButtonBusy(startButton, false, &#39;수집 시작&#39;);
            handleServerError(error, &#39;수집 준비&#39;);
          })
          .startCollectionFromSidebar(payload);
      }

      function cancelCollection() {
        if (!window.confirm(&#39;현재 수집 작업을 취소하시겠습니까? 이미 저장된 행은 유지됩니다.&#39;)) {
          return;
        }

        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            renderState(state);

            if (state &amp;&amp; state.running) {
              showMessage(&#39;취소 요청을 보냈습니다. 현재 API 페이지 처리가 끝나면 안전하게 중단됩니다.&#39;, &#39;info&#39;);
              startPolling();
            } else {
              showMessage(&#39;수집 작업을 취소했습니다.&#39;, &#39;success&#39;);
            }
          })
          .withFailureHandler(function(error) {
            handleServerError(error, &#39;수집 취소&#39;);
          })
          .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 = &#39;&lt;span class=&quot;row&quot; style=&quot;justify-content:center;&quot;&gt;&lt;span class=&quot;spinner&quot;&gt;&lt;/span&gt;&lt;span&gt;&#39; +
            escapeHtml(label) +
            &#39;&lt;/span&gt;&lt;/span&gt;&#39;;
        } else {
          button.textContent = label;
        }
      }

      function showMessage(text, type) {
        const box = document.getElementById(&#39;message&#39;);
        box.textContent = text || &#39;&#39;;
        box.className = &#39;message show &#39; + (type || &#39;info&#39;);
      }

      function clearMessage() {
        const box = document.getElementById(&#39;message&#39;);
        box.textContent = &#39;&#39;;
        box.className = &#39;message&#39;;
      }

      function getBadgeClass(status) {
        if (status === &#39;COMPLETED&#39;) {
          return &#39;success&#39;;
        }

        if (status === &#39;ERROR&#39; || status === &#39;CANCELLED&#39;) {
          return &#39;danger&#39;;
        }

        return &#39;warning&#39;;
      }

      function handleServerError(error, context) {
        const errorInfo = parseServerError(error, context);
        showErrorModal(errorInfo);
      }

      function parseServerError(error, context) {
        if (error &amp;&amp; error.title &amp;&amp; error.summary) {
          return normalizeClientErrorInfo(error);
        }

        const raw = error &amp;&amp; error.message
          ? String(error.message)
          : String(error || &#39;&#39;);

        const structured = extractStructuredError(raw);

        if (structured) {
          return normalizeClientErrorInfo(structured);
        }

        return buildFallbackErrorInfo(raw, context);
      }

      function extractStructuredError(rawText) {
        const raw = String(rawText || &#39;&#39;);
        const markerIndex = raw.indexOf(ERROR_PREFIX);

        if (markerIndex &lt; 0) {
          return null;
        }

        const objectStart = raw.indexOf(
          &#39;{&#39;,
          markerIndex + ERROR_PREFIX.length
        );

        if (objectStart &lt; 0) {
          return null;
        }

        let depth = 0;
        let inString = false;
        let escaped = false;

        for (let index = objectStart; index &lt; raw.length; index += 1) {
          const character = raw.charAt(index);

          if (inString) {
            if (escaped) {
              escaped = false;
            } else if (character === &#39;\\&#39;) {
              escaped = true;
            } else if (character === &#39;&quot;&#39;) {
              inString = false;
            }

            continue;
          }

          if (character === &#39;&quot;&#39;) {
            inString = true;
            continue;
          }

          if (character === &#39;{&#39;) {
            depth += 1;
          } else if (character === &#39;}&#39;) {
            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 || &#39;UNKNOWN_ERROR&#39;),
          title: String(info.title || &#39;작업을 완료하지 못했습니다.&#39;),
          summary: String(
            info.summary ||
            &#39;처리 중 예상하지 못한 문제가 발생했습니다.&#39;
          ),
          causes: Array.isArray(info.causes)
            ? info.causes.map(String)
            : [],
          steps: Array.isArray(info.steps)
            ? info.steps.map(String)
            : [],
          helpLabel: String(info.helpLabel || &#39;&#39;),
          helpUrl: String(info.helpUrl || &#39;&#39;),
          focusTarget: String(info.focusTarget || &#39;&#39;),
          actionLabel: String(info.actionLabel || &#39;확인&#39;),
          technical:
            info.technical &amp;&amp; typeof info.technical === &#39;object&#39;
              ? info.technical
              : {}
        };
      }

      function buildFallbackErrorInfo(rawText, context) {
        const raw = String(rawText || &#39;&#39;);
        const lower = raw.toLowerCase();
        const technical = {
          errorCode: &#39;UNCLASSIFIED_CLIENT_ERROR&#39;,
          context: String(context || &#39;작업 실행&#39;)
        };

        if (
          lower.indexOf(&#39;api key not valid&#39;) &gt;= 0 ||
          lower.indexOf(&#39;keyinvalid&#39;) &gt;= 0 ||
          lower.indexOf(&#39;api_key_invalid&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;API_KEY_INVALID&#39;,
            title: &#39;API 키를 사용할 수 없습니다.&#39;,
            summary: &#39;입력한 API 키가 정확하지 않거나 현재 사용할 수 없는 상태입니다.&#39;,
            causes: [
              &#39;키 일부가 누락되었거나 다른 값을 붙여 넣었을 수 있습니다.&#39;,
              &#39;Google Cloud에서 기존 키를 삭제하거나 재발급했을 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
              &#39;실제 API 키 값을 전체 복사합니다.&#39;,
              &#39;사이드바에서 키를 다시 입력하고 저장합니다.&#39;
            ],
            helpLabel: &#39;API 키 관리 화면 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;API 키 다시 입력&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;API_KEY_INVALID&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;access not configured&#39;) &gt;= 0 ||
          lower.indexOf(&#39;accessnotconfigured&#39;) &gt;= 0 ||
          lower.indexOf(&#39;has not been used in project&#39;) &gt;= 0 ||
          lower.indexOf(&#39;api_disabled&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;YOUTUBE_API_NOT_ENABLED&#39;,
            title: &#39;YouTube Data API v3가 활성화되어 있지 않습니다.&#39;,
            summary: &#39;API 키가 속한 Google Cloud 프로젝트에서 YouTube Data API v3 사용 설정을 켜야 합니다.&#39;,
            causes: [
              &#39;API 키를 만든 프로젝트에서 YouTube Data API v3를 아직 활성화하지 않았습니다.&#39;,
              &#39;API를 활성화한 프로젝트와 API 키를 발급한 프로젝트가 다를 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console에서 API 키를 만든 프로젝트를 선택합니다.&#39;,
              &#39;‘API 및 서비스 → 라이브러리’로 이동합니다.&#39;,
              &#39;‘YouTube Data API v3’를 검색하고 ‘사용’을 누릅니다.&#39;,
              &#39;설정 후 1~5분 뒤 API 키를 다시 저장합니다.&#39;
            ],
            helpLabel: &#39;YouTube Data API 설정 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/library/youtube.googleapis.com&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;설정 확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;YOUTUBE_API_NOT_ENABLED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;commentsdisabled&#39;) &gt;= 0 ||
          lower.indexOf(&#39;comments disabled&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;COMMENTS_DISABLED&#39;,
            title: &#39;이 영상에서는 댓글을 수집할 수 없습니다.&#39;,
            summary: &#39;영상 게시자가 댓글 기능을 사용하지 않도록 설정했습니다.&#39;,
            causes: [
              &#39;영상의 댓글 기능이 꺼져 있거나 YouTube 정책에 따라 제한되었습니다.&#39;
            ],
            steps: [
              &#39;영상 페이지에서 댓글 영역이 표시되는지 확인합니다.&#39;,
              &#39;댓글이 허용된 다른 영상 주소를 입력합니다.&#39;
            ],
            focusTarget: &#39;videoUrl&#39;,
            actionLabel: &#39;영상 주소 다시 입력&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;COMMENTS_DISABLED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;quotaexceeded&#39;) &gt;= 0 ||
          lower.indexOf(&#39;quota_exceeded&#39;) &gt;= 0 ||
          lower.indexOf(&#39;daily limit&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;YOUTUBE_QUOTA_EXCEEDED&#39;,
            title: &#39;YouTube API의 오늘 할당량을 모두 사용했습니다.&#39;,
            summary: &#39;현재 프로젝트에서 오늘 사용할 수 있는 YouTube Data API 할당량이 남아 있지 않습니다.&#39;,
            causes: [
              &#39;같은 프로젝트의 다른 작업에서도 YouTube API를 사용했을 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console에서 YouTube Data API의 오늘 사용량을 확인합니다.&#39;,
              &#39;일일 할당량이 갱신된 뒤 다시 실행합니다.&#39;,
              &#39;다음 수집에서는 요청 댓글 수를 줄입니다.&#39;
            ],
            helpLabel: &#39;YouTube API 할당량 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas&#39;,
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;YOUTUBE_QUOTA_EXCEEDED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;authorization is required&#39;) &gt;= 0 ||
          lower.indexOf(&#39;you do not have permission&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;,
            title: &#39;Google 권한 승인이 필요합니다.&#39;,
            summary: &#39;현재 계정이 스프레드시트 또는 외부 API 요청 권한을 아직 승인하지 않았습니다.&#39;,
            causes: [
              &#39;Apps Script를 처음 실행했거나 새로운 권한이 추가되었습니다.&#39;
            ],
            steps: [
              &#39;스프레드시트 메뉴에서 사이드바를 다시 엽니다.&#39;,
              &#39;권한 검토 화면에서 현재 계정을 선택합니다.&#39;,
              &#39;요청되는 권한을 허용한 뒤 다시 실행합니다.&#39;
            ],
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;timed out&#39;) &gt;= 0 ||
          lower.indexOf(&#39;maximum execution time&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;,
            title: &#39;한 번의 실행 가능 시간을 초과했습니다.&#39;,
            summary: &#39;댓글이 많아 현재 실행 구간이 종료되었습니다.&#39;,
            causes: [
              &#39;대량 댓글 또는 답글 처리 시간이 길어졌습니다.&#39;
            ],
            steps: [
              &#39;진행 상태가 자동으로 이어지는지 확인합니다.&#39;,
              &#39;멈춘 경우 ‘중단 지점부터 실행’을 한 번 실행합니다.&#39;,
              &#39;반복되면 수집 개수를 줄입니다.&#39;
            ],
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;
            })
          });
        }

        return normalizeClientErrorInfo({
          code: &#39;UNEXPECTED_ERROR&#39;,
          title: &#39;예상하지 못한 오류가 발생했습니다.&#39;,
          summary: &#39;오류 유형을 자동으로 판별하지 못했습니다. 입력값과 Apps Script 실행 기록을 확인해야 합니다.&#39;,
          causes: [
            &#39;네트워크 연결, Google 서비스 상태, 권한 또는 실행 환경에서 문제가 발생했을 수 있습니다.&#39;
          ],
          steps: [
            &#39;사이드바를 닫았다가 다시 열고 같은 작업을 한 번 더 실행합니다.&#39;,
            &#39;Apps Script 편집기의 왼쪽 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;,
            &#39;API 키 저장 상태와 영상 주소를 다시 확인합니다.&#39;
          ],
          actionLabel: &#39;확인&#39;,
          technical: technical
        });
      }

      function showErrorModal(errorInfo) {
        const info = normalizeClientErrorInfo(errorInfo);
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);
        const title = document.getElementById(&#39;errorModalTitle&#39;);
        const code = document.getElementById(&#39;errorModalCode&#39;);
        const summary = document.getElementById(&#39;errorModalSummary&#39;);
        const causeSection = document.getElementById(&#39;errorCauseSection&#39;);
        const stepSection = document.getElementById(&#39;errorStepSection&#39;);
        const causeList = document.getElementById(&#39;errorCauseList&#39;);
        const stepList = document.getElementById(&#39;errorStepList&#39;);
        const helpLink = document.getElementById(&#39;errorHelpLink&#39;);
        const primaryButton = document.getElementById(&#39;errorModalPrimaryButton&#39;);

        clearMessage();

        title.textContent = info.title;
        code.textContent = &#39;오류 코드: &#39; + info.code;
        summary.textContent = info.summary;

        renderTextList(causeList, info.causes);
        renderTextList(stepList, info.steps);

        causeSection.classList.toggle(&#39;hidden&#39;, info.causes.length === 0);
        stepSection.classList.toggle(&#39;hidden&#39;, info.steps.length === 0);

        if (
          info.helpUrl &amp;&amp;
          /^https:\/\/console\.cloud\.google\.com\//.test(info.helpUrl)
        ) {
          helpLink.href = info.helpUrl;
          helpLink.textContent = info.helpLabel || &#39;Google Cloud 설정 열기&#39;;
          helpLink.classList.remove(&#39;hidden&#39;);
        } else {
          helpLink.removeAttribute(&#39;href&#39;);
          helpLink.textContent = &#39;&#39;;
          helpLink.classList.add(&#39;hidden&#39;);
        }

        primaryButton.textContent = info.actionLabel || &#39;확인&#39;;
        pendingFocusTarget = info.focusTarget || &#39;&#39;;

        renderTechnicalInfo(info.technical);

        backdrop.classList.remove(&#39;hidden&#39;);
        document.body.style.overflow = &#39;hidden&#39;;

        window.setTimeout(function() {
          primaryButton.focus();
        }, 0);
      }

      function renderTextList(listElement, items) {
        listElement.innerHTML = &#39;&#39;;

        (items || []).forEach(function(item) {
          const listItem = document.createElement(&#39;li&#39;);
          listItem.textContent = String(item);
          listElement.appendChild(listItem);
        });
      }

      function renderTechnicalInfo(technical) {
        const details = document.getElementById(&#39;errorTechnicalDetails&#39;);
        const grid = document.getElementById(&#39;errorTechnicalGrid&#39;);
        const data = technical &amp;&amp; typeof technical === &#39;object&#39;
          ? technical
          : {};

        const labels = {
          errorCode: &#39;분류 코드&#39;,
          httpStatus: &#39;HTTP 상태&#39;,
          reason: &#39;API 사유 코드&#39;,
          resource: &#39;요청 자원&#39;,
          videoId: &#39;영상 ID&#39;,
          context: &#39;실행 단계&#39;
        };

        grid.innerHTML = &#39;&#39;;

        Object.keys(labels).forEach(function(key) {
          if (
            data[key] === undefined ||
            data[key] === null ||
            data[key] === &#39;&#39;
          ) {
            return;
          }

          const label = document.createElement(&#39;div&#39;);
          label.className = &#39;technical-label&#39;;
          label.textContent = labels[key];

          const value = document.createElement(&#39;div&#39;);
          value.className = &#39;technical-value&#39;;
          value.textContent = String(data[key]);

          grid.appendChild(label);
          grid.appendChild(value);
        });

        details.classList.toggle(&#39;hidden&#39;, grid.children.length === 0);
        details.open = false;
      }

      function closeErrorModal() {
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);

        if (backdrop.classList.contains(&#39;hidden&#39;)) {
          return;
        }

        backdrop.classList.add(&#39;hidden&#39;);
        document.body.style.overflow = &#39;&#39;;
        pendingFocusTarget = &#39;&#39;;
      }

      function handleErrorModalPrimaryAction() {
        const focusTarget = pendingFocusTarget;
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);

        backdrop.classList.add(&#39;hidden&#39;);
        document.body.style.overflow = &#39;&#39;;
        pendingFocusTarget = &#39;&#39;;

        focusErrorTarget(focusTarget);
      }

      function focusErrorTarget(targetId) {
        if (!targetId) {
          return;
        }

        if (targetId === &#39;apiKey&#39;) {
          document.getElementById(&#39;apiSavedView&#39;).classList.add(&#39;hidden&#39;);
          document.getElementById(&#39;apiMissingView&#39;).classList.remove(&#39;hidden&#39;);
        }

        const target = document.getElementById(targetId);

        if (target) {
          window.setTimeout(function() {
            target.focus();

            if (typeof target.select === &#39;function&#39;) {
              target.select();
            }
          }, 0);
        }
      }

      function formatNumber(value) {
        return Number(value || 0).toLocaleString(&#39;ko-KR&#39;);
      }

      function escapeHtml(value) {
        return String(value || &#39;&#39;)
          .replace(/&amp;/g, &#39;&amp;amp;&#39;)
          .replace(/&lt;/g, &#39;&amp;lt;&#39;)
          .replace(/&gt;/g, &#39;&amp;gt;&#39;)
          .replace(/&quot;/g, &#39;&amp;quot;&#39;)
          .replace(/&#39;/g, &#39;&amp;#039;&#39;);
      }
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</pre></section><section><h3>Youtube Data API v3 KEY</h3><pre>AIzaSyDETqF5gzbFh3DG4U0N-rjT1yK2asM-1X4</pre></section><h3>교안 및 학습자료</h3><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F1786407837256_1347027__%EA%B0%95%EC%9D%98_%EA%B9%80%EB%AF%BC%EA%B8%B0_AI_%EC%9C%B5%ED%95%A9_%EC%88%98%EC%97%85_%EC%84%A4%EA%B3%84.pdf?alt=media&amp;token=1348edb9-4175-4d67-86ad-e6a3dd0c347c">[강의] 김민기_AI 융합 수업 설계.pdf</a></li><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F1786420110600_921248__%EA%B0%95%EC%9D%98_%EA%B9%80%EB%AF%BC%EA%B8%B0_AI_%EC%9C%B5%ED%95%A9_%EC%88%98%EC%97%85_%EC%82%AC%EB%A1%80.pdf?alt=media&amp;token=6b5b5f0f-5e8f-47f0-bffe-2134ef7a6628">[강의] 김민기_AI 융합 수업 사례.pdf</a></li></ul><h3>관련 링크</h3><ul><li><a href="https://clovanote.naver.com/">네이버 클로바노트</a></li><li><a href="https://drive.google.com/file/d/1bTxR-DyK3I4i1ZRCdyVb_lWFEobrtOKe/view">음성 전사 실습</a></li><li><a href="https://brunch.co.kr/@mystoryg/156">Youtube Data API 키 발급 방법 안내</a></li></ul></section><section><h2>(4차시) 앱스스크립트</h2><h3>교안 및 학습자료</h3><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F1786407821295_1381233__%EA%B0%95%EC%9D%98_%EA%B9%80%EB%AF%BC%EA%B8%B0_%EC%95%B1%EC%8A%A4%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8.pdf?alt=media&amp;token=ea0fda47-2da3-4999-9b7f-c6aae8e7d841">[강의] 김민기_앱스스크립트.pdf</a></li></ul></section><section><h2>(5차시) 바른 API로 생기부 검토</h2><section><h3>Code.gs</h3><pre>// =================================================================
// [0] 초기 설정 및 메뉴
// =================================================================

function onOpen() {
  SpreadsheetApp.getUi().createMenu(&#39;민기쌤 생기부 도우미&#39;)
      .addItem(&#39;🚀 사이드바 열기&#39;, &#39;openSidebar&#39;) 
      .addToUi();
}

function renderView_(viewName) {
  var template = HtmlService.createTemplateFromFile(&#39;Sidebar&#39;);
  template.initialView = viewName || &#39;sidebar&#39;;
  return template.evaluate();
}

function openSidebar() {
  ensureBannedWordSheet_();
  var html = renderView_(&#39;sidebar&#39;)
      .setTitle(&#39;민기쌤 생기부 맞춤법 도우미&#39;)
      .setWidth(300); 
  SpreadsheetApp.getUi().showSidebar(html);
}

function openSpellCheckViewer() {
  var html = renderView_(&#39;spellcheck&#39;)
      .setWidth(1000) 
      .setHeight(700);
  SpreadsheetApp.getUi().showModelessDialog(html, &#39;📝 Bareun 맞춤법 검사 및 수정&#39;);
}

function openBannedWordViewer() {
  var html = renderView_(&#39;banned&#39;)
      .setWidth(1000)
      .setHeight(700);
  SpreadsheetApp.getUi().showModelessDialog(html, &#39;🚫 기재 금지어 점검&#39;);
}

// =================================================================
// [1] Bareun API 키 및 UI 설정
// =================================================================

function getBareunUserProperties_() {
  ensureBannedWordSheet_();
  var props = PropertiesService.getUserProperties();
  var currentPolicy = &#39;BAREUN_ONLY_KEYS_V3&#39;;
  var previousPolicy = props.getProperty(&#39;API_KEY_POLICY_VERSION&#39;);

  // 정책 변경 시 현재 기능에서 쓰지 않는 예전 사용자 설정을 한 번 정리합니다.
  if (previousPolicy !== currentPolicy) {
    var allowedProperties = {
      UI_STATE: true,
      API_KEY_POLICY_VERSION: true
    };
    // 바로 이전 사용자 입력형 정책에서 저장한 키만 보존합니다.
    if (previousPolicy === &#39;USER_SUPPLIED_KEYS_V1&#39; ||
        previousPolicy === &#39;BAREUN_ONLY_KEYS_V1&#39; ||
        previousPolicy === &#39;BAREUN_ONLY_KEYS_V2&#39;) {
      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(&#39;UI_STATE&#39;) || &#39;{}&#39;);
    } catch (e) {
      previousUiState = {};
    }
    var currentUiState = {};
    [&#39;card-api&#39;, &#39;sub-api-settings&#39;, &#39;card-util&#39;].forEach(function(id) {
      if (previousUiState[id]) currentUiState[id] = true;
    });
    props.setProperty(&#39;UI_STATE&#39;, JSON.stringify(currentUiState));
    props.setProperty(&#39;API_KEY_POLICY_VERSION&#39;, currentPolicy);
  }

  return props;
}

function saveUserBareunKey(bareunKey) {
  var props = getBareunUserProperties_();
  var cleanBareunKey = String(bareunKey || &#39;&#39;).trim();

  if (!cleanBareunKey) {
    throw new Error(&#39;Bareun API 키를 입력해 주세요. 이 도우미는 API 키를 제공하지 않습니다.&#39;);
  }

  props.setProperty(&#39;USER_BAREUN_KEY&#39;, cleanBareunKey);
  return { hasBareun: true };
}

function checkUserBareunStatus() {
  var props = getBareunUserProperties_();
  return { hasBareun: !!props.getProperty(&#39;USER_BAREUN_KEY&#39;) };
}

function clearUserBareunKey() {
  var props = getBareunUserProperties_();
  props.deleteProperty(&#39;USER_BAREUN_KEY&#39;);
  return { hasBareun: false };
}

function getBareunKey() {
  var key = getBareunUserProperties_().getProperty(&#39;USER_BAREUN_KEY&#39;);
  if (!key) throw new Error(&quot;Bareun API 키가 없습니다. 사이드바 상단에서 등록해주세요.&quot;);
  return key;
}

// =================================================================
// [2] Bareun 맞춤법 검사
// =================================================================

function getSelectedDataForSpellCheck() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var sheetName = sheet.getName();
  var range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);
  var rows = [];
  var startRow = range.getRow();
  var startCol = range.getColumn();
  var values = range.getValues();
  for (var i = 0; i &lt; values.length; i++) {
    for (var j = 0; j &lt; values[0].length; j++) {
      var text = values[i][j].toString();
      if (text.trim() !== &quot;&quot;) {
        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(&#39;검사한 시트를 찾을 수 없습니다: &#39; + item.sheetName);
    sheet.getRange(item.row, item.col).setValue(item.text);
  });
  return updates.length;
}

function callBareunApi(text, userKey) {
  var url = &quot;https://api.bareun.ai/bareun.RevisionService/CorrectError&quot;;
  var options = {
    &quot;method&quot;: &quot;post&quot;, &quot;contentType&quot;: &quot;application/json&quot;, &quot;headers&quot;: { &quot;api-key&quot;: userKey }, &quot;muteHttpExceptions&quot;: true,
    &quot;payload&quot;: JSON.stringify({ &quot;document&quot;: { &quot;content&quot;: text, &quot;language&quot;: &quot;ko-KR&quot; }, &quot;encoding_type&quot;: &quot;UTF16&quot; })
  };
  var response = UrlFetchApp.fetch(url, options);
  var result = JSON.parse(response.getContentText());
  
  if (response.getResponseCode() !== 200) {
    return { error: &quot;API 오류: &quot; + response.getResponseCode() };
  }
  return { revised: result.revised, helps: result.helps || {}, rawBlocks: result.revisedBlocks || result.revised_blocks || [], error: null };
}

// =================================================================
// [3] 기재 금지어 관리
// =================================================================

var BANNED_WORD_SHEET_NAME_ = &#39;기재금지어관리&#39;;
var BANNED_WORD_HEADERS_ = [&#39;번호&#39;, &#39;기재 금지어&#39;];
var LEGACY_BANNED_WORD_HEADERS_ = [&#39;유형&#39;, &#39;기재 금지어&#39;, &#39;대체 표현&#39;];
var ONE_COLUMN_BANNED_WORD_HEADERS_ = [&#39;기재 금지어&#39;];

function getLegacyBannedWordList_() {
  var saved = PropertiesService.getUserProperties().getProperty(&#39;BANNED_WORDS_DB&#39;);
  if (!saved) return getDefaultBannedList_();

  try {
    var parsed = JSON.parse(saved);
    if (!Array.isArray(parsed)) return getDefaultBannedList_();
    return parsed
      .map(function(item) {
        return String(item &amp;&amp; item.k || &#39;&#39;).trim();
      })
      .filter(function(keyword) { return keyword !== &#39;&#39;; });
  } catch (e) {
    return getDefaultBannedList_();
  }
}

function initializeBannedWordSheet_(sheet, list) {
  sheet.getRange(1, 1, 1, BANNED_WORD_HEADERS_.length)
    .setValues([BANNED_WORD_HEADERS_])
    .setBackground(&#39;#fce8e6&#39;)
    .setFontColor(&#39;#a50e0e&#39;)
    .setFontWeight(&#39;bold&#39;)
    .setHorizontalAlignment(&#39;center&#39;);

  var rows = list.map(function(keyword, index) {
    return [index + 1, keyword];
  });
  if (rows.length &gt; 0) sheet.getRange(2, 1, rows.length, 2).setValues(rows);

  sheet.setFrozenRows(1);
  sheet.setColumnWidth(1, 60);
  sheet.setColumnWidth(2, 260);
  sheet.setTabColor(&#39;#d93025&#39;);
}

function migrateLegacyBannedWordSheet_(sheet) {
  var lastColumn = sheet.getLastColumn();
  var legacyColumn;
  var clearColumns;

  if (lastColumn &gt;= 3 &amp;&amp; sheet.getRange(1, 1, 1, 3).getDisplayValues()[0].join(&#39;|&#39;) === LEGACY_BANNED_WORD_HEADERS_.join(&#39;|&#39;)) {
    legacyColumn = 2;
    clearColumns = 3;
  } else if (sheet.getRange(1, 1, 1, 1).getDisplayValues()[0][0] === ONE_COLUMN_BANNED_WORD_HEADERS_[0]) {
    legacyColumn = 1;
    clearColumns = 1;
  } else {
    return false;
  }

  var lastRow = sheet.getLastRow();
  var keywords = lastRow &lt; 2 ? [] : sheet.getRange(2, legacyColumn, lastRow - 1, 1)
    .getDisplayValues()
    .map(function(row) { return String(row[0] || &#39;&#39;).trim(); })
    .filter(function(keyword) { return keyword !== &#39;&#39;; });

  var clearRows = Math.max(sheet.getLastRow(), 1);
  sheet.getRange(1, 1, clearRows, clearColumns).clearContent();
  initializeBannedWordSheet_(sheet, keywords);
  return true;
}

function ensureBannedWordSheet_() {
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  if (!spreadsheet) throw new Error(&#39;활성 스프레드시트를 찾을 수 없습니다.&#39;);

  var originalSheet = spreadsheet.getActiveSheet();
  var originalRange = spreadsheet.getActiveRange();
  var lock = LockService.getDocumentLock() || LockService.getScriptLock();
  lock.waitLock(30000);

  var sheet;
  try {
    sheet = spreadsheet.getSheetByName(BANNED_WORD_SHEET_NAME_);
    if (!sheet) {
      sheet = spreadsheet.insertSheet(BANNED_WORD_SHEET_NAME_);
      initializeBannedWordSheet_(sheet, getLegacyBannedWordList_());
    } else {
      // 이전 형식은 금지어만 보존하여 번호·기재금지어 두 열 목록으로 안전하게 바꿉니다.
      migrateLegacyBannedWordSheet_(sheet);
    }

    // 시트로 옮긴 뒤 예전 사용자별 저장값은 더 이상 별도 원본으로 남기지 않습니다.
    PropertiesService.getUserProperties().deleteProperty(&#39;BANNED_WORDS_DB&#39;);
    return sheet;
  } finally {
    if (originalSheet &amp;&amp; sheet &amp;&amp; originalSheet.getSheetId() !== sheet.getSheetId()) {
      spreadsheet.setActiveSheet(originalSheet);
      if (originalRange) spreadsheet.setActiveRange(originalRange);
    }
    lock.releaseLock();
  }
}

function openBannedWordManagementSheet() {
  var sheet = ensureBannedWordSheet_();
  sheet.activate();
  sheet.getRange(&#39;A1&#39;).activate();
  return sheet.getName();
}

function getDefaultBannedList_() {
  return [
    &quot;MBTI&quot;, &quot;커리어넷&quot;, &quot;메이저맵&quot;, &quot;패들렛&quot;, &quot;띵커벨&quot;, &quot;알로&quot;,
    &quot;미리캔버스&quot;, &quot;망고보드&quot;, &quot;유튜버&quot;, &quot;유튜브&quot;, &quot;넷플릭스&quot;, &quot;왓챠&quot;,
    &quot;티빙&quot;, &quot;네이버 폼&quot;, &quot;구글 폼&quot;, &quot;OECD&quot;, &quot;UN&quot;, &quot;유네스코&quot;, &quot;WTO&quot;,
    &quot;WHO&quot;, &quot;그린피스&quot;, &quot;앰네스티&quot;, &quot;IMF&quot;, &quot;인스타그램&quot;, &quot;페이스북&quot;,
    &quot;카카오톡&quot;, &quot;네이버&quot;, &quot;구글&quot;, &quot;클래스 카드&quot;, &quot;구글 클래스룸&quot;, &quot;게더타운&quot;,
    &quot;제페토&quot;, &quot;아이패드&quot;, &quot;갤럭시 탭&quot;, &quot;비트코인&quot;, &quot;TED&quot;, &quot;반크&quot;, &quot;TOEIC&quot;,
    &quot;TOEFL&quot;, &quot;TEPS&quot;, &quot;대회&quot;, &quot;수상&quot;, &quot;한자능력검정&quot;
  ];
}

function getBannedWordList_() {
  var sheet = ensureBannedWordSheet_();
  var header = sheet.getRange(1, 1, 1, 2).getDisplayValues()[0];
  if (header.join(&#39;|&#39;) !== BANNED_WORD_HEADERS_.join(&#39;|&#39;)) {
    throw new Error(&#39;기재금지어관리 시트의 1행은 &quot;번호 | 기재 금지어&quot; 순서여야 합니다.&#39;);
  }

  var lastRow = sheet.getLastRow();
  if (lastRow &lt; 2) return [];

  var values = sheet.getRange(2, 1, lastRow - 1, 2).getDisplayValues();
  var seen = {};
  var list = [];

  values.forEach(function(row, index) {
    var rowNumber = index + 2;
    var number = String(row[0] || &#39;&#39;).trim();
    var keyword = String(row[1] || &#39;&#39;).trim();

    if (!number &amp;&amp; !keyword) return;
    if (!/^[1-9]\d*$/.test(number)) {
      throw new Error(&#39;기재금지어관리 시트 &#39; + rowNumber + &#39;행의 번호(A열)는 1 이상의 정수여야 합니다.&#39;);
    }
    if (!keyword) throw new Error(&#39;기재금지어관리 시트 &#39; + rowNumber + &#39;행의 기재 금지어(B열)를 입력해 주세요.&#39;);

    var duplicateKey = normalizeBannedWord_(keyword);
    if (seen[duplicateKey]) {
      throw new Error(&#39;기재금지어관리 시트 &#39; + rowNumber + &#39;행의 기재 금지어가 &#39; + seen[duplicateKey] + &#39;행과 중복됩니다: &#39; + keyword);
    }
    seen[duplicateKey] = rowNumber;
    list.push(keyword);
  });

  return list;
}

function runBannedWordCheck() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var sheetName = sheet.getName();
  var range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);

  var values = range.getValues();
  var startRow = range.getRow();
  var startCol = range.getColumn();
  
  var db = getBannedWordList_();
  if (db.length === 0) return [];
  db.sort(function(a, b) { return normalizeBannedWord_(b).length - normalizeBannedWord_(a).length; });
  var canonicalByNormalizedKeyword = {};
  db.forEach(function(keyword) { canonicalByNormalizedKeyword[normalizeBannedWord_(keyword)] = keyword; });
  var combinedPattern = new RegExp(&#39;(&#39; + db.map(buildWhitespaceInsensitivePattern_).join(&#39;|&#39;) + &#39;)&#39;, &#39;gi&#39;);

  var results = [];

  for (var i = 0; i &lt; values.length; i++) {
    for (var j = 0; j &lt; values[0].length; j++) {
      var original = values[i][j].toString();
      if (!original.trim()) continue;

      var hasIssue = false;
      var logs = [];

      var counts = {};
      var match;
      while ((match = combinedPattern.exec(original)) !== null) {
        var normalizedMatch = normalizeBannedWord_(match[0]);
        var keyword = canonicalByNormalizedKeyword[normalizedMatch];
        if (keyword) {
          hasIssue = true;
          counts[keyword] = (counts[keyword] || 0) + 1;
        }
      }

      Object.keys(counts).forEach(function(keyword) {
        logs.push({ k: keyword, count: counts[keyword] });
      });

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

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

function normalizeBannedWord_(value) {
  return String(value || &#39;&#39;).replace(/\s+/g, &#39;&#39;).toLocaleLowerCase();
}

function buildWhitespaceInsensitivePattern_(keyword) {
  var compact = normalizeBannedWord_(keyword);
  if (!compact) throw new Error(&#39;비어 있는 기재 금지어는 검사할 수 없습니다.&#39;);
  return compact.split(&#39;&#39;).map(function(character) {
    return escapeRegExp(character);
  }).join(&#39;[\\s\\u00A0]*&#39;);
}

// =================================================================
// [4] 로컬 검토 유틸리티
// =================================================================

function unifyToStraightQuotes() {
  var range = SpreadsheetApp.getActiveRange();
  var ui = SpreadsheetApp.getUi();
  if (!range) throw new Error(&#39;선택된 셀이 없습니다.&#39;);
  var response = ui.alert(&#39;따옴표 변환&#39;, &#39;선택 영역의 둥근 따옴표를 곧은 따옴표로 변환하시겠습니까?&#39;, ui.ButtonSet.YES_NO);
  if (response != ui.Button.YES) return;
  try {
    range.createTextFinder(&#39;[‘’]&#39;).useRegularExpression(true).matchEntireCell(false).replaceAllWith(&quot;&#39;&quot;);
    range.createTextFinder(&#39;[“”]&#39;).useRegularExpression(true).matchEntireCell(false).replaceAllWith(&#39;&quot;&#39;);
    SpreadsheetApp.flush();
    ui.alert(&#39;✅ 변환 완료&#39;);
  } catch (e) { ui.alert(&#39;오류: &#39; + e.toString()); }
}

// [신규] 선택 영역 스페이스바 실수(2칸 이상) 수정 함수
function fixDoubleSpaces() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);

  const values = range.getValues();
  let fixCount = 0; // 수정된 셀 개수 카운트

  const newValues = values.map(row =&gt; {
    return row.map(cell =&gt; {
      let str = String(cell);
      // 공백이 2번 이상 연속( /  +/)으로 있으면 1번으로 변경
      // 줄바꿈(\n)은 건드리지 않도록 일반 공백( )만 타겟팅
      if (/ {2,}/.test(str)) {
        fixCount++;
        return str.replace(/ {2,}/g, &#39; &#39;); 
      }
      return cell;
    });
  });

  if (fixCount &gt; 0) {
    range.setValues(newValues);
  }
  
  return fixCount;
}

// =================================================================
// [5] UI 상태 저장/불러오기
// =================================================================

function saveUiState(cardId, isExpanded) {
  var props = PropertiesService.getUserProperties();
  var state = JSON.parse(props.getProperty(&#39;UI_STATE&#39;) || &#39;{}&#39;);
  state[cardId] = isExpanded;
  props.setProperty(&#39;UI_STATE&#39;, JSON.stringify(state));
}

function getUiState() {
  var props = PropertiesService.getUserProperties();
  return JSON.parse(props.getProperty(&#39;UI_STATE&#39;) || &#39;{}&#39;);
}
</pre></section><section><h3>Sidebar.html</h3><pre>&lt;? var allowedViews = [&quot;sidebar&quot;, &quot;spellcheck&quot;, &quot;banned&quot;]; ?&gt;
&lt;? var activeView = allowedViews.indexOf(initialView) &gt;= 0 ? initialView : &quot;sidebar&quot;; ?&gt;
&lt;? if (activeView === &#39;sidebar&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;style&gt;
      * { box-sizing: border-box; }
      body {
        margin: 0;
        padding: 10px 15px 10px 2px;
        background: #f1f3f4;
        color: #202124;
        font-family: &#39;Noto Sans KR&#39;, 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=&quot;password&quot;] {
        width: 100%;
        padding: 10px;
        border: 2px solid #dadce0;
        border-radius: 6px;
        outline: none;
        font-family: inherit;
        font-size: 14px;
      }
      input[type=&quot;password&quot;]: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; }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div class=&quot;card&quot; id=&quot;card-api&quot;&gt;
      &lt;div class=&quot;card-header&quot; onclick=&quot;toggleCard(this)&quot;&gt;
        &lt;div class=&quot;header-title&quot;&gt;🔑 Bareun API 키 설정&lt;/div&gt;
        &lt;span class=&quot;material-icons toggle-icon&quot;&gt;expand_more&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class=&quot;card-body&quot;&gt;
        &lt;div class=&quot;sub-section&quot; id=&quot;sub-api-settings&quot;&gt;
          &lt;div class=&quot;sub-header&quot; onclick=&quot;toggleSub(this)&quot;&gt;
            &lt;span&gt;내 Bareun API 키&lt;/span&gt;
            &lt;span class=&quot;material-icons sub-icon&quot;&gt;expand_more&lt;/span&gt;
          &lt;/div&gt;
          &lt;div class=&quot;sub-body&quot;&gt;
            &lt;div id=&quot;api-loading&quot; class=&quot;api-status&quot;&gt;⌛ API 키 설정 확인 중...&lt;/div&gt;
            &lt;div id=&quot;api-settings&quot; class=&quot;hidden&quot;&gt;
              &lt;div id=&quot;apiStatusSummary&quot; class=&quot;api-status&quot;&gt;&lt;/div&gt;
              &lt;label class=&quot;input-label&quot; for=&quot;bareunKeyInput&quot;&gt;Bareun API 키&lt;/label&gt;
              &lt;input type=&quot;password&quot; id=&quot;bareunKeyInput&quot; autocomplete=&quot;off&quot; placeholder=&quot;본인이 발급받은 키를 입력하세요&quot;&gt;
              &lt;div class=&quot;key-help&quot;&gt;공용 키는 제공하지 않습니다. 키는 사용자별 설정에 저장되고 원문을 화면으로 다시 불러오지 않습니다.&lt;/div&gt;
              &lt;button class=&quot;btn-auth&quot; id=&quot;saveKeyBtn&quot; onclick=&quot;saveBareunKey()&quot;&gt;내 Bareun 키 저장&lt;/button&gt;
              &lt;button class=&quot;btn-secondary hidden&quot; id=&quot;clearKeyBtn&quot; onclick=&quot;resetBareunKey()&quot;&gt;저장된 Bareun 키 삭제&lt;/button&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;card expanded&quot; id=&quot;card-util&quot;&gt;
      &lt;div class=&quot;card-header&quot; onclick=&quot;toggleCard(this)&quot;&gt;
        &lt;div class=&quot;header-title&quot;&gt;📝 선택 영역 검사 및 정리&lt;/div&gt;
        &lt;span class=&quot;material-icons toggle-icon&quot;&gt;expand_more&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class=&quot;card-body&quot;&gt;
        &lt;button class=&quot;btn-warning&quot; id=&quot;checkBtn&quot; onclick=&quot;runSpellCheck()&quot;&gt;선택 영역 Bareun 맞춤법 검사 🔍&lt;/button&gt;
        &lt;div id=&quot;checkStatus&quot; class=&quot;status-box&quot;&gt;&lt;/div&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;runSpaceFix(this)&quot;&gt;선택 영역 연속 공백 정리&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;convertQuotes(this)&quot;&gt;선택 영역 따옴표 통일 (&#39; &quot;)&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;runBannedCheck()&quot;&gt;선택 영역 기재 금지어 검사&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;openBannedWordManagement()&quot;&gt;기재금지어관리 시트 열기&lt;/button&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let currentApiStatus = { hasBareun: false };

      function updateApiState(status) {
        currentApiStatus = { hasBareun: !!(status &amp;&amp; status.hasBareun) };

        const summary = document.getElementById(&#39;apiStatusSummary&#39;);
        const clearButton = document.getElementById(&#39;clearKeyBtn&#39;);
        const spellCheckButton = document.getElementById(&#39;checkBtn&#39;);
        const apiCard = document.getElementById(&#39;card-api&#39;);
        const apiSub = document.getElementById(&#39;sub-api-settings&#39;);

        summary.innerHTML = currentApiStatus.hasBareun
          ? &#39;&lt;div class=&quot;ok&quot;&gt;✅ Bareun 키 저장됨&lt;/div&gt;&#39;
          : &#39;&lt;div class=&quot;missing&quot;&gt;❌ 맞춤법 검사를 사용하려면 Bareun 키가 필요합니다.&lt;/div&gt;&#39;;
        clearButton.classList.toggle(&#39;hidden&#39;, !currentApiStatus.hasBareun);
        spellCheckButton.disabled = !currentApiStatus.hasBareun;
        spellCheckButton.title = currentApiStatus.hasBareun ? &#39;&#39; : &#39;Bareun API 키를 먼저 저장해 주세요.&#39;;

        apiCard.classList.toggle(&#39;expanded&#39;, !currentApiStatus.hasBareun);
        apiSub.classList.toggle(&#39;expanded&#39;, !currentApiStatus.hasBareun);
      }

      function toggleCard(header) {
        const card = header.parentElement;
        const isExpanded = card.classList.toggle(&#39;expanded&#39;);
        google.script.run.saveUiState(card.id, isExpanded);
      }

      function toggleSub(header) {
        const section = header.parentElement;
        const isExpanded = section.classList.toggle(&#39;expanded&#39;);
        google.script.run.saveUiState(section.id, isExpanded);
      }

      function saveBareunKey() {
        const button = document.getElementById(&#39;saveKeyBtn&#39;);
        const keyInput = document.getElementById(&#39;bareunKeyInput&#39;);
        const key = keyInput.value.trim();
        if (!key) {
          alert(&#39;Bareun API 키를 입력해 주세요.&#39;);
          return;
        }

        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;저장 중...&#39;;

        google.script.run
          .withSuccessHandler(function(status) {
            keyInput.value = &#39;&#39;;
            updateApiState(status);
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;✅ Bareun API 키를 사용자 설정에 저장했습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;Bareun API 키 저장 오류: &#39; + error);
          })
          .saveUserBareunKey(key);
      }

      function resetBareunKey() {
        if (!confirm(&#39;저장된 Bareun API 키를 삭제하시겠습니까?&#39;)) return;

        google.script.run
          .withSuccessHandler(function(status) {
            document.getElementById(&#39;bareunKeyInput&#39;).value = &#39;&#39;;
            updateApiState(status);
            alert(&#39;저장된 Bareun API 키를 삭제했습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            alert(&#39;Bareun API 키 삭제 오류: &#39; + error);
          })
          .clearUserBareunKey();
      }

      function runSpellCheck() {
        if (!currentApiStatus.hasBareun) {
          alert(&#39;Bareun API 키를 먼저 저장해 주세요.&#39;);
          return;
        }
        document.getElementById(&#39;checkStatus&#39;).innerText = &#39;맞춤법 검사 창을 여는 중...&#39;;
        google.script.run
          .withSuccessHandler(function() {
            document.getElementById(&#39;checkStatus&#39;).innerText = &#39;&#39;;
          })
          .withFailureHandler(function(error) {
            document.getElementById(&#39;checkStatus&#39;).innerText = &#39;&#39;;
            alert(&#39;오류 발생: &#39; + error);
          })
          .openSpellCheckViewer();
      }

      function runSpaceFix(button) {
        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;정리 중...&#39;;
        google.script.run
          .withSuccessHandler(function(count) {
            button.disabled = false;
            button.innerText = originalText;
            alert(count ? &#39;✅ 총 &#39; + count + &#39;개 셀의 연속 공백을 정리했습니다.&#39; : &#39;수정할 연속 공백이 없습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;오류 발생: &#39; + error);
          })
          .fixDoubleSpaces();
      }

      function convertQuotes(button) {
        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;변환 중...&#39;;
        google.script.run
          .withSuccessHandler(function() {
            button.disabled = false;
            button.innerText = originalText;
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;오류 발생: &#39; + error);
          })
          .unifyToStraightQuotes();
      }

      function runBannedCheck() {
        google.script.run
          .withFailureHandler(function(error) { alert(&#39;오류 발생: &#39; + error); })
          .openBannedWordViewer();
      }

      function openBannedWordManagement() {
        google.script.run
          .withFailureHandler(function(error) { alert(&#39;오류 발생: &#39; + error); })
          .openBannedWordManagementSheet();
      }

      window.onload = function() {
        google.script.run
          .withSuccessHandler(function(status) {
            document.getElementById(&#39;api-loading&#39;).classList.add(&#39;hidden&#39;);
            document.getElementById(&#39;api-settings&#39;).classList.remove(&#39;hidden&#39;);
            updateApiState(status);

            google.script.run.withSuccessHandler(function(states) {
              Object.keys(states || {}).forEach(function(id) {
                const element = document.getElementById(id);
                if (id === &#39;card-api&#39; || id === &#39;sub-api-settings&#39;) return;
                if (element &amp;&amp; states[id]) element.classList.add(&#39;expanded&#39;);
              });
              updateApiState(status);
            }).getUiState();
          })
          .withFailureHandler(function(error) {
            document.getElementById(&#39;api-loading&#39;).classList.add(&#39;hidden&#39;);
            document.getElementById(&#39;api-settings&#39;).classList.remove(&#39;hidden&#39;);
            updateApiState({ hasBareun: false });
            document.getElementById(&#39;apiStatusSummary&#39;).textContent = &#39;설정 확인 오류: &#39; + error;
          })
          .checkUserBareunStatus();
      };
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } else if (activeView === &#39;spellcheck&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch.js&quot;&gt;&lt;/script&gt;
    &lt;style&gt;
      body { padding: 20px; font-family: &#39;Noto Sans KR&#39;, 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; }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;

    &lt;div class=&quot;header-area&quot;&gt;
      &lt;div class=&quot;title&quot;&gt;&lt;span class=&quot;material-icons&quot;&gt;fact_check&lt;/span&gt; 맞춤법 검사 및 수정&lt;/div&gt;
      &lt;div style=&quot;font-size:12px; color:#5f6368; display:flex; gap:15px;&quot;&gt;
        &lt;div&gt;&lt;span style=&quot;background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;&quot;&gt;원본 오류&lt;/span&gt;&lt;/div&gt;
        &lt;div&gt;&lt;span style=&quot;background:#e6f4ea; color:#188038; border-bottom:2px solid #188038; padding:0 4px; font-weight:500;&quot;&gt;수정 제안&lt;/span&gt;&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div id=&quot;mainContent&quot; class=&quot;content-area&quot;&gt;
      &lt;div id=&quot;loaderOverlay&quot; class=&quot;loader-overlay&quot;&gt;
        &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
        &lt;div id=&quot;loadingText&quot;&gt;데이터를 불러오는 중입니다...&lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;cardsContainer&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;footer-bar&quot;&gt;
      &lt;button class=&quot;btn btn-cancel&quot; onclick=&quot;google.script.host.close()&quot;&gt;닫기&lt;/button&gt;
      &lt;button class=&quot;btn btn-apply&quot; id=&quot;btnApply&quot; onclick=&quot;applyAll()&quot; disabled&gt;적용하기 (0건)&lt;/button&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let itemData = []; 
      let isFirstRender = true; 

      window.onload = function() {
        google.script.run
          .withSuccessHandler(processData)
          .withFailureHandler(function(err) {
             document.getElementById(&#39;loaderOverlay&#39;).innerHTML = 
               `&lt;div class=&quot;error-msg&quot;&gt;❌ 초기화 오류&lt;br&gt;${err}&lt;br&gt;선택된 셀이 있는지 확인해주세요.&lt;/div&gt;`;
          })
          .getSelectedDataForSpellCheck();
      };

      function processData(rows) {
        if (rows.length === 0) {
          document.getElementById(&#39;loaderOverlay&#39;).innerHTML = &#39;&lt;div class=&quot;error-msg&quot;&gt;선택된 셀이 없거나 빈 셀입니다.&lt;/div&gt;&#39;;
          return;
        }

        document.getElementById(&#39;loadingText&#39;).innerText = `총 ${rows.length}개 항목 분석 중...`;
        const container = document.getElementById(&#39;cardsContainer&#39;);
        container.innerHTML = &quot;&quot;; 
        
        let processedCount = 0;
        const total = rows.length;

        rows.forEach((row, index) =&gt; {
          const div = document.createElement(&#39;div&#39;);
          div.id = `item-${index}`;
          div.className = &#39;check-item&#39;;
          div.innerHTML = `
            &lt;div class=&quot;item-header&quot;&gt;&lt;span&gt;행 ${row.row} (대기 중...)&lt;/span&gt;&lt;/div&gt;
            &lt;div class=&quot;split-view&quot;&gt;&lt;div class=&quot;pane pane-left&quot;&gt;...&lt;/div&gt;&lt;div class=&quot;pane pane-right&quot;&gt;...&lt;/div&gt;&lt;/div&gt;
          `;
          container.appendChild(div);

          google.script.run
            .withSuccessHandler((res) =&gt; {
              renderResult(index, row, res);
              processedCount++;
              updateApplyButton(processedCount, total);
            })
            .withFailureHandler((err) =&gt; {
              renderError(index, row, err);
              processedCount++;
              updateApplyButton(processedCount, total);
            })
            .getBareunCorrection(row.original);
        });
      }

      function renderError(index, rowData, errorMsg) {
        if (isFirstRender) {
          document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
          isFirstRender = false;
        }
        const itemDiv = document.getElementById(`item-${index}`);
        if (!itemDiv) return;
        itemDiv.innerHTML = `&lt;div style=&quot;padding:20px; color:red;&quot;&gt;❌ 분석 실패: ${errorMsg}&lt;/div&gt;`;
      }

      function renderResult(index, rowData, apiResult) {
        if (isFirstRender) {
          document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
          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 = &quot;&quot;;
        const analysisList = [];
        if (apiResult.rawBlocks &amp;&amp; apiResult.rawBlocks.length &gt; 0) {
          traverseBlocks(apiResult.rawBlocks, apiResult.helps, analysisList);
        }

        if (analysisList.length &gt; 0) {
          const listItems = analysisList.map(item =&gt; `
            &lt;li class=&quot;analysis-row&quot;&gt;
              &lt;span class=&quot;err-word&quot;&gt;${item.origin}&lt;/span&gt;
              &lt;span class=&quot;arrow&quot;&gt;→&lt;/span&gt;
              &lt;span class=&quot;fix-word&quot;&gt;${item.revised}&lt;/span&gt;
              &lt;span class=&quot;fix-reason&quot;&gt;${item.msg}&lt;/span&gt;
            &lt;/li&gt;
          `).join(&quot;&quot;);

          helpListHtml = `
            &lt;div class=&quot;analysis-box&quot;&gt;
              &lt;div class=&quot;analysis-title&quot;&gt;&lt;span class=&quot;material-icons&quot; style=&quot;font-size:14px;&quot;&gt;info&lt;/span&gt; 상세 수정 내역 (${analysisList.length}건)&lt;/div&gt;
              &lt;ul class=&quot;analysis-list&quot;&gt;${listItems}&lt;/ul&gt;
            &lt;/div&gt;
          `;
        }

        itemDiv.innerHTML = `
          &lt;div class=&quot;item-header&quot;&gt;
            &lt;span&gt;행 ${rowData.row}&lt;/span&gt;
            &lt;span style=&quot;color:#1a73e8;&quot;&gt;분석 완료&lt;/span&gt;
          &lt;/div&gt;
          &lt;div class=&quot;split-view&quot;&gt;
            &lt;div class=&quot;pane pane-left&quot;&gt;${diff.left}&lt;/div&gt;
            &lt;div class=&quot;pane pane-right&quot; contenteditable=&quot;true&quot; spellcheck=&quot;false&quot; id=&quot;edit-${index}&quot; oninput=&quot;updateBytes(${index})&quot;&gt;${diff.right}&lt;/div&gt;
          &lt;/div&gt;
          ${helpListHtml}
          &lt;div class=&quot;byte-info&quot;&gt;
            &lt;div class=&quot;byte-box&quot; style=&quot;border-right:1px solid #eee;&quot;&gt;원본: ${getByteLength(rowData.original)} byte&lt;/div&gt;
            &lt;div class=&quot;byte-box&quot;&gt;수정본: &lt;span id=&quot;byte-${index}&quot; class=&quot;byte-val&quot;&gt;${getByteLength(apiResult.revised)}&lt;/span&gt; byte&lt;/div&gt;
          &lt;/div&gt;
        `;
        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 &amp;&amp; block.nested.length &gt; 0) {
            traverseBlocks(block.nested, helps, resultsArray);
          } else {
            const origin = block.origin.content || &quot;&quot;;
            const revised = block.revised || &quot;&quot;;
            if (origin === revised) return;
            let msg = &quot;교정&quot;;
            if (block.revisions &amp;&amp; block.revisions.length &gt; 0) {
              const helpId = block.revisions[0].helpId || block.revisions[0].help_id;
              if (helps &amp;&amp; !helps[helpId]) return;
              if (helpId &amp;&amp; helps &amp;&amp; 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 === &#39;undefined&#39;) {
           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 = &quot;&quot;;
          for (var i = 0; i &lt; 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 = &quot;&quot;;
        let rightHtml = &quot;&quot;;
        
        diffs.forEach(function(part) {
          var op = part[0]; 
          var chars = part[1];
          var text = &quot;&quot;;
          
          // 유니코드를 다시 원래 단어로 복원
          for (var i = 0; i &lt; chars.length; i++) {
            text += tokenArr[chars.charCodeAt(i)];
          }
          
          if (op === 0) {
            leftHtml += text; rightHtml += text;
          } else if (op === -1) {
            leftHtml += `&lt;span class=&quot;diff-del&quot;&gt;${text}&lt;/span&gt;`;
          } else if (op === 1) {
            rightHtml += `&lt;span class=&quot;diff-add&quot;&gt;${text}&lt;/span&gt;`;
          }
        });
        
        return { left: leftHtml, right: rightHtml };
      }

      function getByteLength(s) {
        let b = 0; if (!s) return 0;
        for (let i = 0; i &lt; s.length; i++) {
          const c = s.charCodeAt(i);
          if (c === 10) b += 2; else if (c &gt;&gt; 11) b += 3; else if (c &gt;&gt; 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(&#39;btnApply&#39;);
        btn.innerText = `적용하기 (${current} / ${total})`;
        if (current === total) btn.disabled = false;
      }

      function applyAll() {
        if (!confirm(&quot;검사된 내용으로 원본 데이터를 덮어쓰시겠습니까?\n(되돌릴 수 없습니다)&quot;)) return;
        const updates = [];
        itemData.forEach((item, index) =&gt; {
          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(&#39;btnApply&#39;);
        btn.disabled = true; btn.innerText = &quot;저장 중...&quot;;
        google.script.run.withSuccessHandler((count) =&gt; {
          alert(`✅ 총 ${count}건이 수정되었습니다.`);
          google.script.host.close();
        }).applySpellCheckResults(updates);
      }
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } else if (activeView === &#39;banned&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;style&gt;
      body { padding: 20px; font-family: &#39;Noto Sans KR&#39;, 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: &quot;•&quot;; 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); } }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;

    &lt;div class=&quot;header-area&quot;&gt;
      &lt;div class=&quot;title&quot;&gt;&lt;span class=&quot;material-icons&quot;&gt;block&lt;/span&gt; 기재 금지어 검사&lt;/div&gt;
      &lt;div id=&quot;checkLegend&quot; style=&quot;font-size:12px; color:#5f6368; display:flex; gap:15px;&quot;&gt;
        &lt;div&gt;&lt;span style=&quot;background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;&quot;&gt;금지어 발견&lt;/span&gt;&lt;/div&gt;
        &lt;div&gt;오른쪽 수정안에서 문맥에 맞게 직접 고쳐 주세요.&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div id=&quot;checkView&quot; class=&quot;content-area&quot;&gt;
      &lt;div id=&quot;loaderOverlay&quot; class=&quot;loader-overlay&quot;&gt;
        &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
        &lt;div id=&quot;loadingText&quot;&gt;데이터를 분석 중입니다...&lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;cardsContainer&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;footer-bar&quot; id=&quot;footerCheck&quot;&gt;
      &lt;button class=&quot;btn btn-cancel&quot; onclick=&quot;google.script.host.close()&quot;&gt;닫기&lt;/button&gt;
      &lt;button class=&quot;btn btn-apply&quot; id=&quot;btnApply&quot; onclick=&quot;applyAll()&quot; disabled&gt;적용하기 (0건)&lt;/button&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let itemData = []; 
      let renderQueue = [];
      const CHUNK_SIZE = 20;

      window.onload = function() {
        runCheck();
      };

      function runCheck() {
        document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;flex&#39;;
        document.getElementById(&#39;loadingText&#39;).innerText = &quot;데이터를 분석 중입니다...&quot;;
        document.getElementById(&#39;cardsContainer&#39;).innerHTML = &quot;&quot;;
        
        google.script.run
          .withSuccessHandler(startRenderSequence)
          .withFailureHandler(err =&gt; alert(&quot;오류: &quot; + err))
          .runBannedWordCheck();
      }

      function startRenderSequence(rows) {
        document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
        const container = document.getElementById(&#39;cardsContainer&#39;);
        
        if (!rows || rows.length === 0) {
          container.innerHTML = &#39;&lt;div style=&quot;text-align:center; padding:50px; color:#5f6368;&quot;&gt;금지어가 발견되지 않았습니다. 🎉&lt;/div&gt;&#39;;
          document.getElementById(&#39;btnApply&#39;).disabled = true;
          return;
        }

        itemData = rows; 
        renderQueue = [...rows]; 
        processRenderQueue(); 

        document.getElementById(&#39;btnApply&#39;).innerText = `적용하기 (${rows.length}건)`;
        document.getElementById(&#39;btnApply&#39;).disabled = false;
      }

      function processRenderQueue() {
        if (renderQueue.length === 0) return;

        const container = document.getElementById(&#39;cardsContainer&#39;);
        const chunk = renderQueue.splice(0, CHUNK_SIZE);
        
        chunk.forEach((row) =&gt; {
          const originalIndex = itemData.findIndex(r =&gt; r.row === row.row &amp;&amp; r.col === row.col);
          let logHtml = &quot;&quot;;
          if (row.logs &amp;&amp; row.logs.length &gt; 0) {
            const listItems = row.logs.map(log =&gt; {
               const count = log.count &gt; 1 ? ` (${log.count}회)` : &#39;&#39;;
               return `&lt;li class=&quot;analysis-row&quot;&gt;&lt;span&gt;&lt;span class=&quot;err-word&quot;&gt;${escapeHtml(log.k)}&lt;/span&gt;${count} 기재금지어가 발견되었습니다. 오른쪽 수정안에서 문맥에 맞게 직접 고쳐 주세요.&lt;/span&gt;&lt;/li&gt;`;
            }).join(&quot;&quot;);
            
            logHtml = `
              &lt;div class=&quot;analysis-box&quot;&gt;
                &lt;ul class=&quot;analysis-list&quot;&gt;${listItems}&lt;/ul&gt;
              &lt;/div&gt;
            `;
          }

          const div = document.createElement(&#39;div&#39;);
          div.className = &#39;check-item&#39;;
          div.innerHTML = `
            &lt;div class=&quot;item-header&quot;&gt;
              &lt;span&gt;행 ${row.row}&lt;/span&gt;
              &lt;span style=&quot;color:#d93025; font-weight:bold;&quot;&gt;금지어 발견&lt;/span&gt;
            &lt;/div&gt;
            &lt;div class=&quot;split-view&quot;&gt;
              &lt;div class=&quot;pane pane-left&quot;&gt;${highlightBannedWords(row.original, row.logs)}&lt;/div&gt;
              &lt;div class=&quot;pane pane-right&quot; contenteditable=&quot;true&quot; spellcheck=&quot;false&quot; id=&quot;edit-${originalIndex}&quot;&gt;&lt;/div&gt;
            &lt;/div&gt;
            ${logHtml}
          `;
          container.appendChild(div);
          // 원문을 그대로 넣습니다. 기재금지어 검사는 자동 대체·삭제를 전혀 하지 않습니다.
          document.getElementById(`edit-${originalIndex}`).textContent = row.original;
        });

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

      function escapeHtml(value) {
        return String(value)
          .replace(/&amp;/g, &#39;&amp;amp;&#39;)
          .replace(/&lt;/g, &#39;&amp;lt;&#39;)
          .replace(/&gt;/g, &#39;&amp;gt;&#39;)
          .replace(/&quot;/g, &#39;&amp;quot;&#39;)
          .replace(/&#39;/g, &#39;&amp;#39;&#39;);
      }

      function escapeRegExp(value) {
        return String(value).replace(/[.*+?^${}()|[\]\\]/g, &#39;\\$&amp;&#39;);
      }

      function highlightBannedWords(text, logs) {
        const keywords = (logs || []).map(log =&gt; log.k).filter(Boolean);
        if (keywords.length === 0) return escapeHtml(text);
        const fragments = keywords
          .sort((a, b) =&gt; compactBannedWord(b).length - compactBannedWord(a).length)
          .map(buildWhitespaceInsensitivePattern);
        const pattern = new RegExp(`(${fragments.join(&#39;|&#39;)})`, &#39;gi&#39;);
        return String(text).split(pattern).map((part, index) =&gt; {
          const safeText = escapeHtml(part);
          return index % 2 === 1 ? `&lt;span class=&quot;diff-del&quot;&gt;${safeText}&lt;/span&gt;` : safeText;
        }).join(&#39;&#39;);
      }

      function compactBannedWord(value) {
        return String(value || &#39;&#39;).replace(/\s+/g, &#39;&#39;).toLocaleLowerCase();
      }

      function buildWhitespaceInsensitivePattern(keyword) {
        return compactBannedWord(keyword)
          .split(&#39;&#39;)
          .map(escapeRegExp)
          .join(&#39;[\\s\\u00A0]*&#39;);
      }

      function applyAll() {
        if (!confirm(&quot;수정된 내용으로 원본을 덮어쓰시겠습니까?&quot;)) return;
        
        const updates = [];
        itemData.forEach((item, index) =&gt; {
          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) =&gt; {
           alert(`${c}건 수정 완료!`);
           google.script.host.close();
        }).applySpellCheckResults(updates);
      }

    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } ?&gt;
</pre></section><h3>관련 링크</h3><ul><li><a href="https://nara-speller.co.kr/speller/">하나씩 검사하기 - 바른한글</a></li><li><a href="https://bareun.ai/api-key">API로 대량 검사하기 - 바른AI</a></li></ul></section>]]></description></item><item><title>2026 AI·디지털 교육 역량강화 연수(국어 기초) - 8. 11. 공주대학교 천안캠퍼스</title><link>https://민기.한국/70</link><guid isPermaLink="true">https://민기.한국/70</guid><pubDate>Tue, 11 Aug 2026 00:30:22 GMT</pubDate><description><![CDATA[<h1>2026 AI·디지털 교육 역량강화 연수(국어 기초) - 8. 11. 공주대학교 천안캠퍼스</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-08-11</dd><dt>장소</dt><dd>공주대학교 8공학관</dd></dl><p>충청남도교육청 주관 연수입니다.</p><section><h2>관련 링크</h2><ul><li><a href="https://t-portalapp.netlify.app/">사전 설문</a></li><li><a href="https://t-portalapp.netlify.app/">만족도 설문</a></li></ul></section><section><h2>(1차시) 마주온 활용</h2><h3>교안 및 학습자료</h3><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F1786407792353_3621111__%EA%B0%95%EC%9D%98_%EA%B9%80%EB%AF%BC%EA%B8%B0_%EB%A7%88%EC%A3%BC%EC%98%A8_%ED%99%9C%EC%9A%A9.pdf?alt=media&amp;token=28ddd45c-aacf-4dd2-96d3-dcfaab8430d5">[강의] 김민기_마주온 활용.pdf</a></li></ul></section><section><h2>(2차시) 토의·토론</h2><h3>교안 및 학습자료</h3><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F1786407850805_349071__%EA%B0%95%EC%9D%98_%EA%B9%80%EB%AF%BC%EA%B8%B0_%EA%B5%AD%EC%96%B4%EA%B3%BC_AI_%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C%EC%9D%98_%EC%A3%BC%EC%B2%B4%EC%84%B1_%ED%86%A0%EB%A1%A0.pdf?alt=media&amp;token=d5515dda-fb31-441d-974c-03e9816435b1">[강의] 김민기_국어과 AI 환경에서의 주체성 토론.pdf</a></li></ul></section><section><h2>(3차시) AI 융합 수업 설계</h2><section><h3>Code.gs</h3><pre>const YT_CFG = Object.freeze({
  API_BASE: &#39;https://www.googleapis.com/youtube/v3&#39;,

  API_KEY_PROP: &#39;YT_API_KEY&#39;,
  STATE_PROP: &#39;YT_COMMENT_COLLECTOR_STATE&#39;,
  LAST_RESULT_PROP: &#39;YT_COMMENT_COLLECTOR_LAST_RESULT&#39;,
  CANCEL_REQUEST_PROP: &#39;YT_COMMENT_COLLECTOR_CANCEL_REQUEST&#39;,

  OUTPUT_SHEET_PREFIX: &#39;시트&#39;,
  INTERNAL_SHEET: &#39;_댓글수집_내부&#39;,
  LEGACY_LOG_SHEET: &#39;수집로그&#39;,

  CONTINUE_HANDLER: &#39;continueCommentCollection&#39;,
  STRUCTURED_ERROR_PREFIX: &#39;YT_STRUCTURED_ERROR::&#39;,
  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: [
    &#39;번호&#39;,
    &#39;채널명&#39;,
    &#39;영상 제목&#39;,
    &#39;댓글 작성자&#39;,
    &#39;댓글 내용&#39;,
    &#39;좋아요 수&#39;,
    &#39;작성 시각&#39;,
    &#39;글자수&#39;
  ],

  // 숨김 내부 시트의 열 구조입니다.
  INTERNAL_HEADERS: [
    &#39;comment_id&#39;,
    &#39;parent_comment_id&#39;,
    &#39;display_number&#39;,
    &#39;comment_type&#39;,
    &#39;reply_status&#39;,
    &#39;next_reply_index&#39;,
    &#39;total_reply_count&#39;,
    &#39;reply_page_token&#39;
  ],

  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(&#39;YouTube 댓글 수집&#39;)
    .addItem(&#39;사이드바 열기&#39;, &#39;showYouTubeCommentSidebar&#39;)
    .addSeparator()
    .addItem(&#39;중단 지점부터 실행&#39;, &#39;continueCommentCollection&#39;)
    .addItem(&#39;진행 중인 수집 취소&#39;, &#39;cancelCommentCollection&#39;)
    .addToUi();
}


/**
 * 오른쪽 사이드바를 엽니다.
 */
function showYouTubeCommentSidebar() {
  const html = HtmlService
    .createHtmlOutputFromFile(&#39;Sidebar&#39;)
    .setTitle(&#39;YouTube 댓글 수집기&#39;);

  SpreadsheetApp.getUi().showSidebar(html);
}


/**
 * 사이드바 초기 상태를 반환합니다.
 */
function getSidebarState() {
  const props = getUserProperties_();
  const apiKey = props.getProperty(YT_CFG.API_KEY_PROP) || &#39;&#39;;
  const activeState = loadState_();
  const lastResult = loadLastResult_();
  const cancelRequested =
    props.getProperty(YT_CFG.CANCEL_REQUEST_PROP) === &#39;true&#39;;

  const progress = activeState
    ? buildProgressForClient_(activeState, &#39;RUNNING&#39;)
    : lastResult
      ? buildProgressForClient_(lastResult, lastResult.status || &#39;COMPLETED&#39;)
      : null;

  if (progress &amp;&amp; activeState &amp;&amp; cancelRequested) {
    progress.stage = &#39;취소 처리 중&#39;;
    progress.message = &#39;현재 실행 구간이 끝나는 즉시 안전하게 중단됩니다.&#39;;
  }

  return {
    apiKeyConfigured: Boolean(apiKey),
    apiKeyMask: apiKey ? maskApiKey_(apiKey) : &#39;&#39;,
    running: Boolean(activeState),
    cancelRequested: cancelRequested,
    progress: progress
  };
}


/**
 * API 키를 실제 YouTube Data API 요청으로 검증한 뒤 사용자별 속성에 저장합니다.
 */
function saveApiKey(apiKey) {
  const cleanedKey = String(apiKey || &#39;&#39;).trim();

  if (!cleanedKey) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_REQUIRED&#39;,
      &#39;API 키를 입력해야 합니다.&#39;,
      &#39;Google Cloud에서 발급한 YouTube Data API 키가 입력되지 않았습니다.&#39;,
      [&#39;API 키 입력란이 비어 있습니다.&#39;],
      [
        &#39;Google Cloud Console에서 API 키를 발급합니다.&#39;,
        &#39;API 키 전체를 사이드바 입력란에 붙여 넣습니다.&#39;,
        &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 입력&#39;
      }
    ));
  }

  if (cleanedKey.length &lt; 20) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_FORMAT_INVALID&#39;,
      &#39;API 키 형식을 확인해야 합니다.&#39;,
      &#39;입력한 문자열이 일반적인 Google API 키 길이보다 짧습니다.&#39;,
      [
        &#39;API 키 일부만 복사했을 수 있습니다.&#39;,
        &#39;프로젝트 ID나 다른 값을 입력했을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;키 유형이 ‘API 키’인지 확인합니다.&#39;,
        &#39;API 키 값을 처음부터 끝까지 다시 복사하여 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 다시 입력&#39;
      }
    ));
  }

  validateApiKey_(cleanedKey);

  const props = getUserProperties_();
  props.setProperty(YT_CFG.API_KEY_PROP, cleanedKey);

  // 이전 API 키 오류 안내가 새 키 저장 뒤 다시 나타나지 않도록 정리합니다.
  const previous = loadLastResult_();
  if (previous &amp;&amp; previous.status === &#39;ERROR&#39;) {
    props.deleteProperty(YT_CFG.LAST_RESULT_PROP);
  }

  return getSidebarState();
}


/**
 * 저장된 API 키를 삭제합니다.
 */
function deleteApiKey() {
  if (loadState_()) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_DELETE_BLOCKED&#39;,
      &#39;수집 중에는 API 키를 삭제할 수 없습니다.&#39;,
      &#39;현재 댓글 수집 작업이 저장된 API 키를 사용하고 있습니다.&#39;,
      [&#39;진행 중인 작업이 존재합니다.&#39;],
      [
        &#39;현재 수집을 완료하거나 취소합니다.&#39;,
        &#39;수집 상태가 종료된 뒤 API 키를 삭제합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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_(
        &#39;SPREADSHEET_NOT_FOUND&#39;,
        &#39;댓글을 저장할 스프레드시트를 찾지 못했습니다.&#39;,
        &#39;현재 Apps Script가 스프레드시트와 연결된 상태인지 확인해야 합니다.&#39;,
        [
          &#39;독립 실행형 Apps Script 프로젝트에서 코드를 실행했을 수 있습니다.&#39;,
          &#39;스프레드시트가 닫히거나 접근 권한이 변경되었을 수 있습니다.&#39;
        ],
        [
          &#39;댓글을 저장할 Google 스프레드시트를 엽니다.&#39;,
          &#39;‘확장 프로그램 → Apps Script’에서 이 코드를 실행합니다.&#39;,
          &#39;스프레드시트를 새로고침하고 사이드바를 다시 엽니다.&#39;
        ],
        { actionLabel: &#39;확인&#39; }
      ));
    }

    // 영상과 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: &#39;RUNNING&#39;,
      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: &#39;&#39;,
      threadDone: false,

      currentReplyParentId: &#39;&#39;,
      nextReplyPageToken: &#39;&#39;,

      topCount: 0,
      replyCount: 0,
      threadPages: 0,
      replyPages: 0,
      apiCalls: 1,

      startedAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      message: outputSheet.getName() + &#39;에서 수집을 시작했습니다.&#39;,
      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 &lt; 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 || &#39;&#39;;
        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 = &#39;실행 시간을 고려하여 진행 상태를 저장했습니다.&#39;;
    saveState_(state);
    scheduleContinuation_();

  } catch (error) {
    deleteContinuationTriggers_();

    const errorInfo = normalizeErrorInfo_(error, &#39;댓글 수집&#39;);

    if (errorInfo.code === &#39;API_KEY_INVALID&#39;) {
      getUserProperties_().deleteProperty(YT_CFG.API_KEY_PROP);
    }

    if (commentsSheet) {
      try {
        finalizeCommentsSheet_(commentsSheet);
      } catch (formatError) {
        console.error(formatError);
      }
    }

    if (state) {
      state.status = &#39;ERROR&#39;;
      state.updatedAt = new Date().toISOString();
      state.message = errorInfo.summary;
      state.error = errorInfo;
      saveLastResult_(state, &#39;ERROR&#39;, 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, &#39;true&#39;);
  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 &lt;= 0) {
    state.threadDone = true;
    state.nextThreadPageToken = &#39;&#39;;
    return;
  }

  const params = {
    part: state.includeReplies ? &#39;snippet,replies&#39; : &#39;snippet&#39;,
    videoId: state.videoId,
    maxResults: Math.min(100, remaining),
    textFormat: &#39;plainText&#39;
  };

  if (state.nextThreadPageToken) {
    params.pageToken = state.nextThreadPageToken;
  }

  const data = youtubeGet_(&#39;commentThreads&#39;, 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 &gt;= 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 = &#39;DONE&#39;;
    let nextReplyIndex = 1;

    commentRows.push(
      commentToOutputRow_(state, topComment, topNumber)
    );

    batchIds.add(topCommentId);
    topAdded += 1;

    const inlineReplies =
      thread.replies &amp;&amp; Array.isArray(thread.replies.comments)
        ? thread.replies.comments
        : [];

    if (state.includeReplies &amp;&amp; totalReplyCount &gt; 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 + &#39;_&#39; + nextReplyIndex;
          nextReplyIndex += 1;

          commentRows.push(
            commentToOutputRow_(state, reply, displayNumber)
          );

          internalRows.push(
            buildInternalRow_(
              replyId,
              topCommentId,
              displayNumber,
              &#39;reply&#39;,
              &#39;&#39;,
              &#39;&#39;,
              &#39;&#39;,
              &#39;&#39;
            )
          );

          batchIds.add(replyId);
          replyAdded += 1;
        });
      } else {
        // commentThreads.list의 replies에는 전체 답글이 보장되지 않으므로
        // comments.list로 전체 답글을 다시 가져옵니다.
        replyStatus = &#39;PENDING&#39;;
        nextReplyIndex = 1;
      }
    }

    internalRows.push(
      buildInternalRow_(
        topCommentId,
        &#39;&#39;,
        topNumber,
        &#39;topLevel&#39;,
        replyStatus,
        nextReplyIndex,
        totalReplyCount,
        &#39;&#39;
      )
    );
  });

  appendCollectedRecords_(
    commentsSheet,
    internalSheet,
    commentRows,
    internalRows
  );

  batchIds.forEach(function(id) {
    existingIds.add(id);
  });

  state.topCount += topAdded;
  state.replyCount += replyAdded;

  const reachedTarget = state.topCount &gt;= state.targetTopLevelCount;
  const hasNextPage = Boolean(data.nextPageToken);

  state.nextThreadPageToken =
    !reachedTarget &amp;&amp; hasNextPage
      ? data.nextPageToken
      : &#39;&#39;;

  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 = &#39;&#39;;
    state.nextReplyPageToken = &#39;&#39;;
    return;
  }

  const params = {
    part: &#39;snippet&#39;,
    parentId: state.currentReplyParentId,
    maxResults: 100,
    textFormat: &#39;plainText&#39;
  };

  const pageToken =
    state.nextReplyPageToken || topRecord.replyPageToken || &#39;&#39;;

  if (pageToken) {
    params.pageToken = pageToken;
  }

  const data = youtubeGet_(&#39;comments&#39;, 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) + &#39;_&#39; + nextReplyIndex;

    nextReplyIndex += 1;

    commentRows.push(
      commentToOutputRow_(state, reply, displayNumber)
    );

    internalRows.push(
      buildInternalRow_(
        replyId,
        state.currentReplyParentId,
        displayNumber,
        &#39;reply&#39;,
        &#39;&#39;,
        &#39;&#39;,
        &#39;&#39;,
        &#39;&#39;
      )
    );

    batchIds.add(replyId);
    replyAdded += 1;
  });

  appendCollectedRecords_(
    commentsSheet,
    internalSheet,
    commentRows,
    internalRows
  );

  batchIds.forEach(function(id) {
    existingIds.add(id);
  });

  state.replyCount += replyAdded;

  const nextPageToken = data.nextPageToken || &#39;&#39;;
  const nextStatus = nextPageToken ? &#39;IN_PROGRESS&#39; : &#39;DONE&#39;;

  updateTopReplyProgress_(
    internalSheet,
    topRecord.rowNumber,
    nextStatus,
    nextReplyIndex,
    nextPageToken
  );

  if (nextPageToken) {
    state.nextReplyPageToken = nextPageToken;
  } else {
    state.currentReplyParentId = &#39;&#39;;
    state.nextReplyPageToken = &#39;&#39;;
  }

  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(&#39;IN_PROGRESS&#39;)
    .matchEntireCell(true)
    .useRegularExpression(false)
    .findNext();

  if (!found) {
    found = statusRange
      .createTextFinder(&#39;PENDING&#39;)
      .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] === &#39;PENDING&#39;) {
    internalSheet
      .getRange(rowNumber, YT_CFG.INTERNAL_COL.REPLY_STATUS)
      .setValue(&#39;IN_PROGRESS&#39;);
  }

  return {
    rowNumber: rowNumber,
    commentId: String(values[YT_CFG.INTERNAL_COL.COMMENT_ID - 1] || &#39;&#39;),
    displayNumber: String(
      values[YT_CFG.INTERNAL_COL.DISPLAY_NUMBER - 1] || &#39;&#39;
    ),
    nextReplyIndex: Number(
      values[YT_CFG.INTERNAL_COL.NEXT_REPLY_INDEX - 1] || 1
    ),
    pageToken: String(
      values[YT_CFG.INTERNAL_COL.REPLY_PAGE_TOKEN - 1] || &#39;&#39;
    )
  };
}


/**
 * 댓글 1건을 사용자가 보는 8열 행으로 변환합니다.
 */
function commentToOutputRow_(state, comment, displayNumber) {
  const snippet = (comment &amp;&amp; comment.snippet) || {};
  const text = String(snippet.textDisplay || &#39;&#39;);

  return [
    String(displayNumber),
    safeCellText_(state.videoChannelTitle || &#39;&#39;),
    safeCellText_(state.videoTitle || &#39;&#39;),
    safeCellText_(snippet.authorDisplayName || &#39;&#39;),
    safeCellText_(text),
    Number(snippet.likeCount || 0),
    apiDateToSheetValue_(snippet.publishedAt),
    calculateCommentBytes_(text)
  ];
}


/**
 * 숨김 내부 시트의 행을 만듭니다.
 */
function buildInternalRow_(
  commentId,
  parentCommentId,
  displayNumber,
  commentType,
  replyStatus,
  nextReplyIndex,
  totalReplyCount,
  replyPageToken
) {
  return [
    safeCellText_(commentId || &#39;&#39;),
    safeCellText_(parentCommentId || &#39;&#39;),
    String(displayNumber || &#39;&#39;),
    String(commentType || &#39;&#39;),
    String(replyStatus || &#39;&#39;),
    nextReplyIndex === &#39;&#39; ? &#39;&#39; : Number(nextReplyIndex || 1),
    totalReplyCount === &#39;&#39; ? &#39;&#39; : Number(totalReplyCount || 0),
    safeCellText_(replyPageToken || &#39;&#39;)
  ];
}


/**
 * 댓글 행과 내부 관리 행을 각각 일괄 기록합니다.
 */
function appendCollectedRecords_(
  commentsSheet,
  internalSheet,
  commentRows,
  internalRows
) {
  if (commentRows.length !== internalRows.length) {
    throwStructuredError_(buildErrorInfo_(
      &#39;INTERNAL_ROW_MISMATCH&#39;,
      &#39;댓글 저장 구조를 확인해야 합니다.&#39;,
      &#39;댓글 행과 내부 관리 행의 개수가 일치하지 않습니다.&#39;,
      [&#39;수집 상태를 기록하는 내부 데이터가 일시적으로 어긋났습니다.&#39;],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;사이드바에서 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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 &lt;= 1) {
    return null;
  }

  const found = internalSheet
    .getRange(2, YT_CFG.INTERNAL_COL.COMMENT_ID, lastRow - 1, 1)
    .createTextFinder(String(commentId || &#39;&#39;))
    .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] || &#39;&#39;),
    displayNumber: String(
      values[YT_CFG.INTERNAL_COL.DISPLAY_NUMBER - 1] || &#39;&#39;
    ),
    replyStatus: String(
      values[YT_CFG.INTERNAL_COL.REPLY_STATUS - 1] || &#39;&#39;
    ),
    nextReplyIndex: Number(
      values[YT_CFG.INTERNAL_COL.NEXT_REPLY_INDEX - 1] || 1
    ),
    replyPageToken: String(
      values[YT_CFG.INTERNAL_COL.REPLY_PAGE_TOKEN - 1] || &#39;&#39;
    )
  };
}


/**
 * 최상위 댓글의 답글 수집 진행 정보를 갱신합니다.
 */
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 || &#39;&#39;
    ]]);
}


/**
 * 완료 처리
 */
function finishCollection_(ss, commentsSheet, state) {
  state.status = &#39;COMPLETED&#39;;
  state.updatedAt = new Date().toISOString();
  state.finishedAt = new Date().toISOString();
  state.message = state.topCount &lt; state.targetTopLevelCount
    ? &#39;댓글 &#39; +
      state.topCount +
      &#39;개와 대댓글 &#39; +
      state.replyCount +
      &#39;개를 수집했습니다.&#39;
    : &#39;댓글 수집을 완료했습니다.&#39;;
  state.error = null;

  finalizeCommentsSheet_(commentsSheet);
  saveLastResult_(state, &#39;COMPLETED&#39;, 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) === &#39;true&#39;;
}


/**
 * 취소 처리
 */
function finalizeCancellation_(ss, commentsSheet, state) {
  const message = &#39;사용자가 수집을 취소했습니다. 이미 저장된 댓글은 유지됩니다.&#39;;

  state.status = &#39;CANCELLED&#39;;
  state.updatedAt = new Date().toISOString();
  state.finishedAt = new Date().toISOString();
  state.message = message;
  state.error = null;

  finalizeCommentsSheet_(commentsSheet);
  saveLastResult_(state, &#39;CANCELLED&#39;, 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_(
    &#39;videos&#39;,
    {
      part: &#39;id&#39;,
      id: &#39;jNQXAC9IVRw&#39;
    },
    apiKey
  );
}


/**
 * 영상 메타데이터를 조회합니다.
 */
function getVideoMetadataWithKey_(videoId, apiKey) {
  const data = youtubeGetWithKey_(
    &#39;videos&#39;,
    {
      part: &#39;snippet,statistics&#39;,
      id: videoId
    },
    apiKey
  );

  if (!Array.isArray(data.items) || data.items.length === 0) {
    throwStructuredError_(buildErrorInfo_(
      &#39;VIDEO_NOT_FOUND&#39;,
      &#39;영상 정보를 찾을 수 없습니다.&#39;,
      &#39;입력한 영상 주소에 해당하는 공개 영상을 YouTube Data API에서 조회하지 못했습니다.&#39;,
      [
        &#39;영상 주소나 11자리 영상 ID가 정확하지 않을 수 있습니다.&#39;,
        &#39;영상이 삭제되었거나 비공개 상태일 수 있습니다.&#39;,
        &#39;연령·지역·계정 권한에 따라 접근이 제한된 영상일 수 있습니다.&#39;
      ],
      [
        &#39;브라우저에서 해당 영상 주소가 정상적으로 열리는지 확인합니다.&#39;,
        &#39;주소 표시줄의 영상 URL 전체를 다시 복사합니다.&#39;,
        &#39;사이드바의 영상 주소를 교체한 뒤 수집을 다시 시작합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: { videoId: videoId }
      }
    ));
  }

  const item = data.items[0];
  const snippet = item.snippet || {};
  const statistics = item.statistics || {};

  return {
    title: snippet.title || &#39;&#39;,
    channelId: snippet.channelId || &#39;&#39;,
    channelTitle: snippet.channelTitle || &#39;&#39;,
    commentCount: statistics.commentCount || &#39;&#39;
  };
}


/**
 * 저장된 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 &amp;&amp; value !== null &amp;&amp; value !== &#39;&#39;;
    })
    .map(function(key) {
      return encodeURIComponent(key) + &#39;=&#39; + encodeURIComponent(queryParams[key]);
    })
    .join(&#39;&amp;&#39;);

  const url = YT_CFG.API_BASE + &#39;/&#39; + resource + &#39;?&#39; + query;
  const transientCodes = [429, 500, 502, 503, 504];

  for (let attempt = 0; attempt &lt; 3; attempt += 1) {
    let response;

    try {
      response = UrlFetchApp.fetch(url, {
        method: &#39;get&#39;,
        muteHttpExceptions: true,
        headers: { Accept: &#39;application/json&#39; }
      });
    } catch (networkError) {
      if (attempt &lt; 2) {
        Utilities.sleep(Math.pow(2, attempt) * 1000);
        continue;
      }

      throwStructuredError_(buildErrorInfo_(
        &#39;NETWORK_REQUEST_FAILED&#39;,
        &#39;YouTube 서버에 연결하지 못했습니다.&#39;,
        &#39;Apps Script에서 YouTube Data API로 보내는 외부 요청이 완료되지 않았습니다.&#39;,
        [
          &#39;Google 서비스 또는 네트워크에 일시적인 장애가 발생했을 수 있습니다.&#39;,
          &#39;Apps Script의 외부 요청 권한이 승인되지 않았을 수 있습니다.&#39;
        ],
        [
          &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
          &#39;Apps Script 권한 승인 화면이 나타나면 요청된 권한을 허용합니다.&#39;,
          &#39;Apps Script의 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;
        ],
        {
          actionLabel: &#39;확인&#39;,
          technical: {
            resource: resource,
            originalMessage: String(networkError &amp;&amp; networkError.message || networkError)
          }
        }
      ));
    }

    const statusCode = response.getResponseCode();
    const responseText = response.getContentText();

    if (statusCode &gt;= 200 &amp;&amp; statusCode &lt; 300) {
      return responseText ? JSON.parse(responseText) : {};
    }

    if (transientCodes.indexOf(statusCode) &gt;= 0 &amp;&amp; attempt &lt; 2) {
      Utilities.sleep(Math.pow(2, attempt) * 1000);
      continue;
    }

    const parsedError = parseYouTubeErrorResponse_(responseText);

    throwStructuredError_(classifyYouTubeApiError_(
      statusCode,
      parsedError.reason,
      parsedError.message,
      resource
    ));
  }

  throwStructuredError_(buildErrorInfo_(
    &#39;YOUTUBE_REQUEST_REPEATED_FAILURE&#39;,
    &#39;YouTube API 요청이 반복적으로 실패했습니다.&#39;,
    &#39;일시적인 서버 장애가 재시도 후에도 계속되었습니다.&#39;,
    [&#39;YouTube 또는 Google 네트워크 서비스가 불안정할 수 있습니다.&#39;],
    [
      &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
      &#39;반복되면 Apps Script의 ‘실행’ 기록을 확인합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: { resource: resource } }
  ));
}


function parseYouTubeErrorResponse_(responseText) {
  let reason = &#39;&#39;;
  let message = String(responseText || &#39;&#39;);

  try {
    const parsed = JSON.parse(responseText);

    if (parsed.error) {
      message = parsed.error.message || message;

      if (Array.isArray(parsed.error.errors) &amp;&amp; parsed.error.errors[0]) {
        reason = parsed.error.errors[0].reason || &#39;&#39;;
      }

      if (!reason &amp;&amp; parsed.error.status) {
        reason = parsed.error.status;
      }
    }
  } catch (parseError) {
    // JSON이 아닌 오류 본문은 문자열 그대로 분류합니다.
  }

  return {
    reason: String(reason || &#39;&#39;),
    message: String(message || &#39;&#39;)
  };
}


/**
 * YouTube API 오류를 한국어 모달용 정보로 분류합니다.
 */
function classifyYouTubeApiError_(statusCode, reason, message, resource) {
  const reasonText = String(reason || &#39;&#39;);
  const messageText = String(message || &#39;&#39;);
  const combined = (reasonText + &#39; &#39; + messageText).toLowerCase();
  const technical = {
    httpStatus: statusCode,
    apiReason: reasonText,
    resource: resource,
    originalMessage: messageText
  };

  if (
    combined.indexOf(&#39;keyinvalid&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api key not valid&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api_key_invalid&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_INVALID&#39;,
      &#39;API 키를 사용할 수 없습니다.&#39;,
      &#39;입력한 API 키가 정확하지 않거나 삭제·재발급되어 현재 요청에 사용할 수 없습니다.&#39;,
      [
        &#39;API 키 일부가 누락되었을 수 있습니다.&#39;,
        &#39;Google Cloud에서 기존 키가 삭제되거나 재발급되었을 수 있습니다.&#39;,
        &#39;API 키가 아닌 다른 프로젝트 값을 입력했을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;키 유형이 ‘API 키’인지 확인합니다.&#39;,
        &#39;API 키 전체를 다시 복사합니다.&#39;,
        &#39;사이드바에서 API 키를 다시 입력하고 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;accessnotconfigured&#39;) &gt;= 0 ||
    combined.indexOf(&#39;access not configured&#39;) &gt;= 0 ||
    combined.indexOf(&#39;service_disabled&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api_disabled&#39;) &gt;= 0 ||
    combined.indexOf(&#39;has not been used in project&#39;) &gt;= 0 ||
    combined.indexOf(&#39;it is disabled&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_API_NOT_ENABLED&#39;,
      &#39;YouTube Data API v3가 활성화되어 있지 않습니다.&#39;,
      &#39;API 키가 속한 Google Cloud 프로젝트에서 YouTube Data API v3 사용 설정을 켜야 합니다.&#39;,
      [
        &#39;API 키를 만든 프로젝트에서 YouTube Data API v3를 아직 활성화하지 않았습니다.&#39;,
        &#39;API를 활성화한 프로젝트와 API 키를 발급한 프로젝트가 다를 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console에서 API 키를 만든 프로젝트를 선택합니다.&#39;,
        &#39;‘API 및 서비스 → 라이브러리’로 이동합니다.&#39;,
        &#39;‘YouTube Data API v3’를 검색하고 ‘사용’을 누릅니다.&#39;,
        &#39;설정 반영을 위해 1~5분 기다린 뒤 API 키를 다시 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;YouTube Data API 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/library/youtube.googleapis.com&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;iprefererblocked&#39;) &gt;= 0 ||
    combined.indexOf(&#39;referer&#39;) &gt;= 0 &amp;&amp; combined.indexOf(&#39;blocked&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api key restrictions&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_APPLICATION_RESTRICTION&#39;,
      &#39;API 키의 애플리케이션 제한이 Apps Script 요청을 차단했습니다.&#39;,
      &#39;현재 키에 설정된 웹사이트·IP 제한이 Apps Script의 UrlFetch 요청 방식과 맞지 않습니다.&#39;,
      [
        &#39;HTTP 리퍼러 제한 또는 특정 IP 주소 제한이 적용되어 있을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;사용 중인 API 키의 애플리케이션 제한을 확인합니다.&#39;,
        &#39;Apps Script에서 사용할 수 있는 제한 방식으로 조정합니다.&#39;,
        &#39;API 제한은 ‘YouTube Data API v3’로 유지합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 제한 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;accessnotconfigured&#39;) &lt; 0 &amp;&amp;
    (
      combined.indexOf(&#39;forbidden&#39;) &gt;= 0 &amp;&amp;
      combined.indexOf(&#39;api&#39;) &gt;= 0 &amp;&amp;
      combined.indexOf(&#39;key&#39;) &gt;= 0
    )
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_API_RESTRICTION&#39;,
      &#39;API 키의 API 제한 설정을 확인해야 합니다.&#39;,
      &#39;현재 API 키가 YouTube Data API v3 호출을 허용하지 않을 수 있습니다.&#39;,
      [
        &#39;API 키의 ‘API 제한’ 목록에서 YouTube Data API v3가 빠져 있을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;사용 중인 API 키를 선택합니다.&#39;,
        &#39;‘API 제한’에서 YouTube Data API v3를 허용합니다.&#39;,
        &#39;저장 후 1~5분 뒤 다시 실행합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 제한 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;quotaexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;dailylimitexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;daily limit&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_QUOTA_EXCEEDED&#39;,
      &#39;YouTube API의 오늘 할당량을 모두 사용했습니다.&#39;,
      &#39;현재 Google Cloud 프로젝트에서 오늘 사용할 수 있는 YouTube Data API 할당량이 남아 있지 않습니다.&#39;,
      [&#39;같은 프로젝트의 다른 프로그램에서도 YouTube API 할당량을 사용했을 수 있습니다.&#39;],
      [
        &#39;Google Cloud Console에서 YouTube Data API 사용량을 확인합니다.&#39;,
        &#39;일일 할당량이 갱신된 뒤 다시 실행합니다.&#39;,
        &#39;필요하면 다음 수집의 댓글 개수를 줄입니다.&#39;
      ],
      {
        helpLabel: &#39;YouTube API 할당량 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas&#39;,
        actionLabel: &#39;확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;ratelimitexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;userratelimitexceeded&#39;) &gt;= 0 ||
    statusCode === 429
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_RATE_LIMIT&#39;,
      &#39;짧은 시간에 API 요청이 많이 발생했습니다.&#39;,
      &#39;YouTube Data API가 일시적으로 요청 속도를 제한했습니다.&#39;,
      [&#39;여러 수집 작업을 연속으로 실행했을 수 있습니다.&#39;],
      [
        &#39;현재 작업이 자동 재개되는지 확인합니다.&#39;,
        &#39;몇 분 뒤 다시 실행합니다.&#39;,
        &#39;동시에 여러 스프레드시트에서 같은 API 키를 사용하고 있다면 실행 횟수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (combined.indexOf(&#39;commentsdisabled&#39;) &gt;= 0) {
    return buildErrorInfo_(
      &#39;COMMENTS_DISABLED&#39;,
      &#39;이 영상에서는 댓글을 수집할 수 없습니다.&#39;,
      &#39;영상 게시자가 댓글 기능을 사용하지 않도록 설정했습니다.&#39;,
      [&#39;영상의 댓글 기능이 꺼져 있거나 YouTube 정책에 따라 제한되었습니다.&#39;],
      [
        &#39;영상 페이지에서 댓글 영역이 표시되는지 확인합니다.&#39;,
        &#39;댓글이 허용된 다른 영상 주소를 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;videonotfound&#39;) &gt;= 0 ||
    combined.indexOf(&#39;video not found&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;VIDEO_NOT_FOUND&#39;,
      &#39;영상 정보를 찾을 수 없습니다.&#39;,
      &#39;영상 주소가 잘못되었거나 영상이 삭제·비공개 상태일 수 있습니다.&#39;,
      [
        &#39;영상 ID가 정확하지 않을 수 있습니다.&#39;,
        &#39;영상 공개 상태가 변경되었을 수 있습니다.&#39;
      ],
      [
        &#39;브라우저에서 영상 주소가 정상적으로 열리는지 확인합니다.&#39;,
        &#39;영상 URL 전체를 다시 복사해 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (statusCode &gt;= 500) {
    return buildErrorInfo_(
      &#39;YOUTUBE_SERVER_ERROR&#39;,
      &#39;YouTube 서버에서 일시적인 오류가 발생했습니다.&#39;,
      &#39;YouTube Data API 서버가 현재 요청을 정상적으로 처리하지 못했습니다.&#39;,
      [&#39;YouTube 측의 일시적인 서비스 장애일 수 있습니다.&#39;],
      [
        &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
        &#39;진행 중인 수집이라면 자동 재개 여부를 확인합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (statusCode === 403) {
    return buildErrorInfo_(
      &#39;YOUTUBE_ACCESS_FORBIDDEN&#39;,
      &#39;YouTube 데이터에 접근할 수 없습니다.&#39;,
      &#39;API 키 설정, 영상 공개 상태 또는 Google Cloud 프로젝트 권한을 확인해야 합니다.&#39;,
      [
        &#39;API 키 제한 설정이 요청을 차단했을 수 있습니다.&#39;,
        &#39;영상이 공개 API에서 접근할 수 없는 상태일 수 있습니다.&#39;
      ],
      [
        &#39;API 키의 API 제한과 애플리케이션 제한을 확인합니다.&#39;,
        &#39;영상이 공개 상태인지 확인합니다.&#39;,
        &#39;Google Cloud에서 YouTube Data API v3가 활성화되어 있는지 확인합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (statusCode === 400) {
    return buildErrorInfo_(
      &#39;YOUTUBE_BAD_REQUEST&#39;,
      &#39;YouTube API 요청 형식을 확인해야 합니다.&#39;,
      &#39;영상 주소 또는 요청 매개변수 중 하나를 YouTube API가 처리하지 못했습니다.&#39;,
      [&#39;영상 주소가 올바르지 않거나 API 요청 값이 허용 범위를 벗어났을 수 있습니다.&#39;],
      [
        &#39;영상 주소를 다시 확인합니다.&#39;,
        &#39;수집 개수와 정렬 기준을 확인합니다.&#39;,
        &#39;같은 문제가 계속되면 새 수집으로 다시 시작합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;입력값 확인&#39;,
        technical: technical
      }
    );
  }

  return buildErrorInfo_(
    &#39;YOUTUBE_API_ERROR&#39;,
    &#39;YouTube API 요청을 완료하지 못했습니다.&#39;,
    &#39;YouTube Data API가 요청을 거부하거나 처리하지 못했습니다.&#39;,
    [&#39;API 키, 영상 공개 상태, 할당량 또는 네트워크 상태를 확인해야 합니다.&#39;],
    [
      &#39;API 키 설정과 YouTube Data API 활성화 여부를 확인합니다.&#39;,
      &#39;영상 주소가 공개 영상인지 확인합니다.&#39;,
      &#39;잠시 뒤 다시 실행합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: technical }
  );
}


/**
 * 영상 URL 또는 11자리 영상 ID에서 videoId를 추출합니다.
 */
function extractVideoId_(input) {
  const text = String(input || &#39;&#39;).trim();

  if (/^[A-Za-z0-9_-]{11}$/.test(text)) {
    return text;
  }

  const patterns = [
    /[?&amp;]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 &lt; patterns.length; i += 1) {
    const match = text.match(patterns[i]);

    if (match) {
      return match[1];
    }
  }

  throwStructuredError_(buildErrorInfo_(
    &#39;INVALID_VIDEO_URL&#39;,
    &#39;YouTube 영상 주소를 확인해야 합니다.&#39;,
    &#39;입력한 값에서 11자리 YouTube 영상 ID를 찾지 못했습니다.&#39;,
    [
      &#39;영상 주소 일부가 빠졌을 수 있습니다.&#39;,
      &#39;채널·재생목록 주소를 입력했을 수 있습니다.&#39;
    ],
    [
      &#39;YouTube에서 수집할 영상을 엽니다.&#39;,
      &#39;브라우저 주소 표시줄의 영상 URL 전체를 복사합니다.&#39;,
      &#39;사이드바의 영상 주소 입력란에 붙여 넣습니다.&#39;
    ],
    {
      focusTarget: &#39;videoUrl&#39;,
      actionLabel: &#39;영상 주소 다시 입력&#39;
    }
  ));
}


/**
 * 사이드바 입력값을 검증합니다.
 */
function normalizeSidebarInput_(formData) {
  const form = formData || {};
  const videoUrl = String(form.videoUrl || &#39;&#39;).trim();
  const limit = parseInt(form.limit, 10);

  if (!videoUrl) {
    throwStructuredError_(buildErrorInfo_(
      &#39;VIDEO_URL_REQUIRED&#39;,
      &#39;YouTube 영상 주소를 입력해야 합니다.&#39;,
      &#39;수집 대상 영상 주소가 비어 있습니다.&#39;,
      [&#39;영상 주소 입력란에 값이 없습니다.&#39;],
      [&#39;수집할 YouTube 영상의 URL을 입력합니다.&#39;],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 입력&#39;
      }
    ));
  }

  if (!Number.isInteger(limit)) {
    throwStructuredError_(buildErrorInfo_(
      &#39;COMMENT_LIMIT_INVALID&#39;,
      &#39;수집 개수를 정수로 입력해야 합니다.&#39;,
      &#39;수집할 최상위 댓글 개수가 올바른 정수 형식이 아닙니다.&#39;,
      [&#39;빈 값, 소수 또는 문자가 입력되었을 수 있습니다.&#39;],
      [&#39;1 이상의 정수를 입력합니다.&#39;],
      {
        focusTarget: &#39;limit&#39;,
        actionLabel: &#39;수집 개수 입력&#39;
      }
    ));
  }

  if (limit &lt; YT_CFG.MIN_LIMIT || limit &gt; YT_CFG.MAX_LIMIT) {
    throwStructuredError_(buildErrorInfo_(
      &#39;COMMENT_LIMIT_OUT_OF_RANGE&#39;,
      &#39;수집 개수가 허용 범위를 벗어났습니다.&#39;,
      &#39;수집 개수는 &#39; + YT_CFG.MIN_LIMIT + &#39;개 이상 &#39; +
        YT_CFG.MAX_LIMIT + &#39;개 이하로 입력해야 합니다.&#39;,
      [&#39;입력한 개수가 코드에 설정된 운영 범위를 벗어났습니다.&#39;],
      [
        YT_CFG.MIN_LIMIT + &#39;~&#39; + YT_CFG.MAX_LIMIT +
          &#39; 사이의 정수를 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;limit&#39;,
        actionLabel: &#39;수집 개수 수정&#39;
      }
    ));
  }

  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 ... 순서로 만듭니다.
 *
 * 새 스프레드시트에 기본 빈 &#39;시트1&#39; 하나만 존재하는 경우에는 그 시트를
 * 첫 결과 시트로 사용합니다. 그 이후 수집부터는 항상 새 시트를 추가합니다.
 */
function createNextOutputSheet_(ss) {
  const sheets = ss.getSheets();

  if (
    sheets.length === 1 &amp;&amp;
    sheets[0].getName() === YT_CFG.OUTPUT_SHEET_PREFIX + &#39;1&#39; &amp;&amp;
    isSheetCompletelyBlank_(sheets[0])
  ) {
    prepareOutputSheet_(sheets[0]);
    return sheets[0];
  }

  let maxNumber = 0;
  const namePattern = new RegExp(
    &#39;^&#39; + escapeRegExp_(YT_CFG.OUTPUT_SHEET_PREFIX) + &#39;(\\d+)$&#39;
  );

  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 &amp;&amp; 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(&#39;A:A&#39;).setNumberFormat(&#39;@&#39;);
  formatCommentsSheet_(sheet);
}


/**
 * 새 수집을 위해 숨김 내부 시트만 초기화합니다.
 * 이전 결과 시트의 내용은 변경하지 않습니다.
 */
function resetInternalData_(ss) {
  const internal = ss.getSheetByName(YT_CFG.INTERNAL_SHEET);

  if (!internal) {
    throwStructuredError_(buildErrorInfo_(
      &#39;INTERNAL_SHEET_NOT_FOUND&#39;,
      &#39;내부 관리 시트를 준비하지 못했습니다.&#39;,
      &#39;댓글 수집 상태를 저장하는 숨김 시트가 존재하지 않습니다.&#39;,
      [&#39;내부 시트 생성 과정이 완료되지 않았습니다.&#39;],
      [&#39;사이드바를 닫았다가 다시 열고 새 수집을 시작합니다.&#39;],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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 &gt; 0) {
    outputSheet = ss.getSheets().find(function(sheet) {
      return sheet.getSheetId() === expectedId;
    }) || null;
  }

  if (!outputSheet &amp;&amp; state.outputSheetName) {
    outputSheet = ss.getSheetByName(String(state.outputSheetName));
  }

  if (!outputSheet) {
    throwStructuredError_(buildErrorInfo_(
      &#39;OUTPUT_SHEET_NOT_FOUND&#39;,
      &#39;수집 결과 시트를 찾을 수 없습니다.&#39;,
      &#39;현재 수집 작업과 연결된 결과 시트가 삭제되었거나 이름과 식별 정보가 변경되었습니다.&#39;,
      [
        &#39;수집 중 결과 시트를 삭제했을 수 있습니다.&#39;,
        &#39;스프레드시트 구조가 외부 작업으로 변경되었을 수 있습니다.&#39;
      ],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;사이드바에서 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;새 수집 필요&#39; }
    ));
  }

  return outputSheet;
}


function escapeRegExp_(text) {
  return String(text).replace(/[.*+?^${}()|[\]\\]/g, &#39;\\$&amp;&#39;);
}


/**
 * 이전 버전이 만든 수집로그 시트를 제거합니다.
 */
function deleteLegacyLogSheet_(ss) {
  const logSheet = ss.getSheetByName(YT_CFG.LEGACY_LOG_SHEET);

  if (logSheet &amp;&amp; ss.getSheets().length &gt; 1) {
    ss.deleteSheet(logSheet);
  }
}


/**
 * 댓글 시트를 번호 순으로 정렬하고 최종 서식을 적용합니다.
 */
function finalizeCommentsSheet_(sheet) {
  sortCommentsByDisplayNumber_(sheet);
  formatCommentsSheet_(sheet);
}


function sortCommentsByDisplayNumber_(sheet) {
  const lastRow = sheet.getLastRow();

  if (lastRow &lt;= 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] || &#39;&#39;).split(&#39;_&#39;);
    return [
      Number(parts[0] || 0),
      parts.length &gt; 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(&#39;bold&#39;)
    .setWrap(true)
    .setVerticalAlignment(&#39;middle&#39;);

  sheet.getRange(&#39;A:A&#39;).setNumberFormat(&#39;@&#39;);

  if (lastRow &gt; 1) {
    sheet
      .getRange(2, 7, lastRow - 1, 1)
      .setNumberFormat(&#39;yyyy-mm-dd hh:mm:ss&#39;);

    sheet
      .getRange(2, 5, lastRow - 1, 1)
      .setWrap(true)
      .setVerticalAlignment(&#39;top&#39;);

    sheet
      .getRange(2, 1, lastRow - 1, YT_CFG.HEADERS.length)
      .setVerticalAlignment(&#39;top&#39;);

    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 &lt;= 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 &lt;= 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] === &#39;topLevel&#39;) {
      counts.top += 1;
    } else if (row[0] === &#39;reply&#39;) {
      counts.reply += 1;
    }
  });

  return counts;
}


/**
 * 댓글 글자수를 지정된 바이트 규칙으로 계산합니다.
 * - ASCII 영역(영문, 숫자, 공백, 일반 문장부호): 1바이트
 * - 한글을 포함한 비ASCII 유니코드 문자: 코드 포인트당 2바이트
 */
function calculateCommentBytes_(value) {
  const text = String(value || &#39;&#39;);
  let bytes = 0;

  Array.from(text).forEach(function(character) {
    const codePoint = character.codePointAt(0);
    bytes += codePoint &lt;= 0x7F ? 1 : 2;
  });

  return bytes;
}


/**
 * 외부 문자열이 시트 수식으로 실행되는 것을 방지합니다.
 */
function safeCellText_(value) {
  const text = value === undefined || value === null ? &#39;&#39; : String(value);
  return /^[=+\-@]/.test(text) ? &quot;&#39;&quot; + text : text;
}


function apiDateToSheetValue_(value) {
  return value ? new Date(value) : &#39;&#39;;
}


function ensureColumnCount_(sheet, requiredColumns) {
  const current = sheet.getMaxColumns();

  if (current &lt; requiredColumns) {
    sheet.insertColumnsAfter(current, requiredColumns - current);
  }
}


function ensureExactColumnCount_(sheet, requiredColumns) {
  ensureColumnCount_(sheet, requiredColumns);

  const current = sheet.getMaxColumns();

  if (current &gt; 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_(
      &#39;API_KEY_REQUIRED&#39;,
      &#39;API 키 설정이 필요합니다.&#39;,
      &#39;댓글 수집을 시작하기 전에 YouTube Data API 키를 저장해야 합니다.&#39;,
      [&#39;현재 사용자 계정에 저장된 API 키가 없습니다.&#39;],
      [
        &#39;사이드바 상단의 API 키 입력란에 키를 입력합니다.&#39;,
        &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
      ],
      {
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 입력&#39;
      }
    ));
  }

  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 &gt; 8500) {
    throwStructuredError_(buildErrorInfo_(
      &#39;STATE_STORAGE_LIMIT&#39;,
      &#39;수집 진행 상태를 저장할 수 없습니다.&#39;,
      &#39;Apps Script 사용자 속성의 안전 저장 크기를 초과했습니다.&#39;,
      [&#39;진행 상태 데이터가 예상보다 커졌습니다.&#39;],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;댓글 수집 개수를 줄여 새로 실행합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, 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 || &#39;&#39;,
    videoId: state.videoId || &#39;&#39;,
    videoTitle: state.videoTitle || &#39;&#39;,
    targetTopLevelCount: Number(state.targetTopLevelCount || 0),
    topCount: Number(state.topCount || 0),
    replyCount: Number(state.replyCount || 0),
    apiCalls: Number(state.apiCalls || 0),
    startedAt: state.startedAt || &#39;&#39;,
    updatedAt: new Date().toISOString(),
    finishedAt: new Date().toISOString(),
    message: message || &#39;&#39;,
    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_(
      &#39;STATE_VERSION_MISMATCH&#39;,
      &#39;이전 코드에서 시작된 수집 작업을 이어갈 수 없습니다.&#39;,
      &#39;현재 Code.gs의 댓글 저장 구조가 이전 실행 상태와 다릅니다.&#39;,
      [&#39;고정 결과 시트 또는 정렬 기준을 사용하던 이전 코드의 작업 상태가 남아 있습니다.&#39;],
      [
        &#39;사이드바에서 진행 중인 수집을 취소합니다.&#39;,
        &#39;현재 코드로 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;새 수집 필요&#39; }
    ));
  }
}


function normalizeState_(state) {
  state.currentReplyParentId = state.currentReplyParentId || &#39;&#39;;
  state.nextReplyPageToken = state.nextReplyPageToken || &#39;&#39;;
  state.nextThreadPageToken = state.nextThreadPageToken || &#39;&#39;;
  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 || &#39;&#39;);
  state.error = state.error || null;
}


function maskApiKey_(apiKey) {
  const key = String(apiKey || &#39;&#39;);
  return &#39;••••••••••••&#39; + 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 &gt; 0
    ? Math.min(100, Math.round((top / target) * 100))
    : 0;

  let stage = &#39;대기 중&#39;;

  if (status === &#39;RUNNING&#39;) {
    if (state.currentReplyParentId) {
      stage = &#39;답글 수집 중&#39;;
    } else if (!state.threadDone) {
      stage = &#39;최상위 댓글 수집 중&#39;;
    } else {
      stage = &#39;마무리 중&#39;;
    }
  } else if (status === &#39;COMPLETED&#39;) {
    stage = &#39;완료&#39;;
  } else if (status === &#39;ERROR&#39;) {
    stage = &#39;오류&#39;;
  } else if (status === &#39;CANCELLED&#39;) {
    stage = &#39;취소됨&#39;;
  }

  return {
    status: status,
    stage: stage,
    outputSheetId: Number(state.outputSheetId || 0),
    outputSheetName: state.outputSheetName || &#39;&#39;,
    videoId: state.videoId || &#39;&#39;,
    videoTitle: state.videoTitle || &#39;&#39;,
    targetTopLevelCount: target,
    topCount: top,
    replyCount: reply,
    totalRows: top + reply,
    apiCalls: Number(state.apiCalls || 0),
    percent: percent,
    startedAt: state.startedAt || &#39;&#39;,
    updatedAt: state.updatedAt || &#39;&#39;,
    message: state.message || &#39;&#39;,
    error: state.error || null
  };
}


function buildServerProgressMessage_(state) {
  return (
    &#39;최상위 댓글 &#39; +
    state.topCount +
    &#39;/&#39; +
    state.targetTopLevelCount +
    &#39;개, 답글 &#39; +
    state.replyCount +
    &#39;개를 저장했습니다.&#39;
  );
}


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 || &#39;UNKNOWN_ERROR&#39;),
    title: String(title || &#39;작업을 완료하지 못했습니다.&#39;),
    summary: String(summary || &#39;처리 중 문제가 발생했습니다.&#39;),
    causes: Array.isArray(causes) ? causes.map(String) : [],
    steps: Array.isArray(steps) ? steps.map(String) : [],
    helpLabel: String(opts.helpLabel || &#39;&#39;),
    helpUrl: String(opts.helpUrl || &#39;&#39;),
    focusTarget: String(opts.focusTarget || &#39;&#39;),
    actionLabel: String(opts.actionLabel || &#39;확인&#39;),
    technical:
      opts.technical &amp;&amp; typeof opts.technical === &#39;object&#39;
        ? 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 || &#39;&#39;);
  const markerIndex = raw.indexOf(YT_CFG.STRUCTURED_ERROR_PREFIX);

  if (markerIndex &lt; 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 &amp;&amp; error.message
    ? String(error.message)
    : String(error || &#39;&#39;);

  const structured = extractStructuredError_(raw);

  if (structured) {
    return structured;
  }

  const lower = raw.toLowerCase();
  const technical = {
    context: String(context || &#39;작업 실행&#39;),
    originalMessage: raw
  };

  if (
    lower.indexOf(&#39;authorization is required&#39;) &gt;= 0 ||
    lower.indexOf(&#39;you do not have permission&#39;) &gt;= 0 ||
    lower.indexOf(&#39;permissions&#39;) &gt;= 0 &amp;&amp; lower.indexOf(&#39;required&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;,
      &#39;Google 권한 승인이 필요합니다.&#39;,
      &#39;현재 계정이 스프레드시트 또는 외부 API 요청 권한을 아직 승인하지 않았습니다.&#39;,
      [&#39;Apps Script를 처음 실행했거나 새로운 권한이 추가되었습니다.&#39;],
      [
        &#39;스프레드시트 메뉴에서 사이드바를 다시 엽니다.&#39;,
        &#39;권한 검토 화면에서 현재 Google 계정을 선택합니다.&#39;,
        &#39;요청되는 권한을 허용한 뒤 다시 실행합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (
    lower.indexOf(&#39;maximum execution time&#39;) &gt;= 0 ||
    lower.indexOf(&#39;exceeded maximum execution time&#39;) &gt;= 0 ||
    lower.indexOf(&#39;timed out&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;,
      &#39;한 번의 실행 가능 시간을 초과했습니다.&#39;,
      &#39;댓글 또는 답글 처리 시간이 길어 현재 실행 구간이 종료되었습니다.&#39;,
      [&#39;대량 댓글 수집으로 한 번의 Apps Script 실행 시간이 길어졌습니다.&#39;],
      [
        &#39;사이드바를 다시 열어 진행 상태를 확인합니다.&#39;,
        &#39;멈춘 경우 ‘중단 지점부터 실행’을 한 번 실행합니다.&#39;,
        &#39;같은 문제가 반복되면 수집 개수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (
    lower.indexOf(&#39;service invoked too many times&#39;) &gt;= 0 ||
    lower.indexOf(&#39;quota&#39;) &gt;= 0 &amp;&amp; lower.indexOf(&#39;apps script&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_SERVICE_QUOTA&#39;,
      &#39;Apps Script 서비스 사용 한도를 초과했습니다.&#39;,
      &#39;현재 Google 계정 또는 스크립트의 일일 서비스 사용량이 허용 범위를 넘었습니다.&#39;,
      [&#39;짧은 시간에 많은 외부 요청이나 스프레드시트 쓰기 작업이 실행되었습니다.&#39;],
      [
        &#39;잠시 뒤 다시 실행합니다.&#39;,
        &#39;같은 날 반복되면 다음 날 다시 실행합니다.&#39;,
        &#39;한 번에 수집하는 댓글 개수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  return buildErrorInfo_(
    &#39;UNEXPECTED_ERROR&#39;,
    &#39;예상하지 못한 오류가 발생했습니다.&#39;,
    &#39;오류 유형을 자동으로 판별하지 못했습니다.&#39;,
    [
      &#39;네트워크, 권한, Google 서비스 상태 또는 실행 환경에서 문제가 발생했을 수 있습니다.&#39;
    ],
    [
      &#39;사이드바를 닫았다가 다시 열고 같은 작업을 한 번 더 실행합니다.&#39;,
      &#39;Apps Script 편집기의 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;,
      &#39;API 키와 영상 주소를 다시 확인합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: technical }
  );
}
</pre></section><section><h3>Sidebar.html</h3><pre>&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;meta charset=&quot;utf-8&quot;&gt;
    &lt;style&gt;
      :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, &#39;Noto Sans KR&#39;, 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=&#39;text&#39;],
      input[type=&#39;password&#39;],
      input[type=&#39;number&#39;] {
        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);
        }
      }
    &lt;/style&gt;
  &lt;/head&gt;

  &lt;body&gt;
    &lt;h1&gt;YouTube 댓글 수집기&lt;/h1&gt;
    &lt;p class=&quot;subtitle&quot;&gt;YouTube Data API v3 → Google Sheets&lt;/p&gt;

    &lt;div id=&quot;message&quot; class=&quot;message&quot;&gt;&lt;/div&gt;

    &lt;section class=&quot;card&quot;&gt;
      &lt;h2&gt;1. API 키&lt;/h2&gt;

      &lt;div id=&quot;apiMissingView&quot;&gt;
        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;apiKey&quot;&gt;YouTube Data API 키&lt;/label&gt;
          &lt;input
            id=&quot;apiKey&quot;
            type=&quot;password&quot;
            autocomplete=&quot;off&quot;
            placeholder=&quot;AIza...&quot;
          &gt;
          &lt;div class=&quot;help&quot;&gt;
            최초 1회 저장합니다. 키는 사용자별 Apps Script 속성에 보관됩니다.
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;button id=&quot;saveApiButton&quot; class=&quot;primary&quot; type=&quot;button&quot;&gt;
          API 키 확인 후 저장
        &lt;/button&gt;
      &lt;/div&gt;

      &lt;div id=&quot;apiSavedView&quot; class=&quot;hidden&quot;&gt;
        &lt;div class=&quot;row between&quot;&gt;
          &lt;div class=&quot;status-line&quot;&gt;
            &lt;span class=&quot;badge success&quot;&gt;설정 완료&lt;/span&gt;
            &lt;span id=&quot;apiKeyMask&quot; class=&quot;masked-key&quot;&gt;&lt;/span&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;div class=&quot;row&quot; style=&quot;margin-top: 10px;&quot;&gt;
          &lt;button id=&quot;changeApiButton&quot; class=&quot;secondary small-button&quot; type=&quot;button&quot;&gt;
            키 변경
          &lt;/button&gt;
          &lt;button id=&quot;deleteApiButton&quot; class=&quot;danger small-button&quot; type=&quot;button&quot;&gt;
            키 삭제
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/section&gt;

    &lt;section class=&quot;card soft&quot;&gt;
      &lt;h2&gt;2. 수집 설정&lt;/h2&gt;

      &lt;div id=&quot;lockedMessage&quot; class=&quot;locked-message&quot;&gt;
        API 키를 저장하면 수집 설정이 활성화됩니다.
      &lt;/div&gt;

      &lt;fieldset id=&quot;collectorFieldset&quot; disabled&gt;
        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;videoUrl&quot;&gt;YouTube 영상 주소&lt;/label&gt;
          &lt;input
            id=&quot;videoUrl&quot;
            type=&quot;text&quot;
            placeholder=&quot;https://www.youtube.com/watch?v=...&quot;
          &gt;
        &lt;/div&gt;

        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;limit&quot;&gt;수집할 최상위 댓글 수&lt;/label&gt;
          &lt;input
            id=&quot;limit&quot;
            type=&quot;number&quot;
            min=&quot;1&quot;
            max=&quot;50000&quot;
            step=&quot;1&quot;
            value=&quot;100&quot;
          &gt;
          &lt;div class=&quot;help&quot;&gt;
            1~50,000개. 답글 포함 시 실제 저장 행 수가 이 값보다 많아질 수 있습니다.
          &lt;/div&gt;
        &lt;/div&gt;


        &lt;div class=&quot;field check-row&quot;&gt;
          &lt;input id=&quot;includeReplies&quot; type=&quot;checkbox&quot; checked&gt;
          &lt;label for=&quot;includeReplies&quot;&gt;
            대댓글도 모두 수집
          &lt;/label&gt;
        &lt;/div&gt;

        &lt;div class=&quot;help&quot; style=&quot;margin-bottom: 10px;&quot;&gt;
          새 수집을 시작할 때마다 ‘시트1’, ‘시트2’, ‘시트3’ 형식의 새 시트가 생성됩니다. 기존 수집 결과는 유지됩니다.
        &lt;/div&gt;

        &lt;button id=&quot;startButton&quot; class=&quot;primary&quot; type=&quot;button&quot;&gt;
          수집 시작
        &lt;/button&gt;
      &lt;/fieldset&gt;
    &lt;/section&gt;

    &lt;section id=&quot;progressCard&quot; class=&quot;card hidden&quot;&gt;
      &lt;div class=&quot;row between&quot;&gt;
        &lt;h2 style=&quot;margin: 0;&quot;&gt;진행 상태&lt;/h2&gt;
        &lt;span id=&quot;progressBadge&quot; class=&quot;badge warning&quot;&gt;대기 중&lt;/span&gt;
      &lt;/div&gt;

      &lt;div id=&quot;progressTitle&quot; class=&quot;progress-title&quot; style=&quot;margin-top: 10px;&quot;&gt;&lt;/div&gt;

      &lt;div class=&quot;progress-bar&quot;&gt;
        &lt;div id=&quot;progressFill&quot; class=&quot;progress-fill&quot;&gt;&lt;/div&gt;
      &lt;/div&gt;

      &lt;div class=&quot;metric-grid&quot;&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;댓글&lt;/div&gt;
          &lt;div id=&quot;topMetric&quot; class=&quot;metric-value&quot;&gt;0 / 0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;대댓글&lt;/div&gt;
          &lt;div id=&quot;replyMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;전체 저장 행&lt;/div&gt;
          &lt;div id=&quot;totalMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;API 호출 횟수&lt;/div&gt;
          &lt;div id=&quot;apiCallMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;p id=&quot;progressMessage&quot; class=&quot;help&quot;&gt;&lt;/p&gt;

      &lt;button id=&quot;cancelButton&quot; class=&quot;danger hidden&quot; type=&quot;button&quot; style=&quot;width: 100%; margin-top: 12px;&quot;&gt;
        진행 중인 수집 취소
      &lt;/button&gt;
    &lt;/section&gt;


    &lt;div
      id=&quot;errorModalBackdrop&quot;
      class=&quot;modal-backdrop hidden&quot;
      role=&quot;dialog&quot;
      aria-modal=&quot;true&quot;
      aria-labelledby=&quot;errorModalTitle&quot;
    &gt;
      &lt;div class=&quot;modal-panel&quot;&gt;
        &lt;div class=&quot;modal-header&quot;&gt;
          &lt;div class=&quot;modal-icon&quot; aria-hidden=&quot;true&quot;&gt;!&lt;/div&gt;

          &lt;div class=&quot;modal-heading&quot;&gt;
            &lt;h2 id=&quot;errorModalTitle&quot; class=&quot;modal-title&quot;&gt;
              문제를 확인해야 합니다.
            &lt;/h2&gt;
            &lt;div id=&quot;errorModalCode&quot; class=&quot;modal-code&quot;&gt;&lt;/div&gt;
          &lt;/div&gt;

          &lt;button
            id=&quot;errorModalCloseButton&quot;
            class=&quot;modal-close&quot;
            type=&quot;button&quot;
            aria-label=&quot;오류 안내 닫기&quot;
          &gt;
            ×
          &lt;/button&gt;
        &lt;/div&gt;

        &lt;div class=&quot;modal-body&quot;&gt;
          &lt;div id=&quot;errorModalSummary&quot; class=&quot;modal-summary&quot;&gt;&lt;/div&gt;

          &lt;section id=&quot;errorCauseSection&quot; class=&quot;modal-section&quot;&gt;
            &lt;h3&gt;발생 가능한 원인&lt;/h3&gt;
            &lt;ul id=&quot;errorCauseList&quot;&gt;&lt;/ul&gt;
          &lt;/section&gt;

          &lt;section id=&quot;errorStepSection&quot; class=&quot;modal-section&quot;&gt;
            &lt;h3&gt;해결 방법&lt;/h3&gt;
            &lt;ol id=&quot;errorStepList&quot;&gt;&lt;/ol&gt;
          &lt;/section&gt;

          &lt;details id=&quot;errorTechnicalDetails&quot; class=&quot;technical-details&quot;&gt;
            &lt;summary&gt;기술 정보 보기&lt;/summary&gt;
            &lt;div id=&quot;errorTechnicalGrid&quot; class=&quot;technical-grid&quot;&gt;&lt;/div&gt;
          &lt;/details&gt;
        &lt;/div&gt;

        &lt;div class=&quot;modal-actions&quot;&gt;
          &lt;a
            id=&quot;errorHelpLink&quot;
            class=&quot;modal-help-link hidden&quot;
            href=&quot;#&quot;
            target=&quot;_blank&quot;
            rel=&quot;noopener noreferrer&quot;
          &gt;&lt;/a&gt;

          &lt;button
            id=&quot;errorModalPrimaryButton&quot;
            class=&quot;primary&quot;
            type=&quot;button&quot;
          &gt;
            확인
          &lt;/button&gt;

          &lt;button
            id=&quot;errorModalDismissButton&quot;
            class=&quot;secondary&quot;
            type=&quot;button&quot;
          &gt;
            닫기
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;script&gt;
      const ERROR_PREFIX = &#39;YT_STRUCTURED_ERROR::&#39;;

      let currentState = null;
      let pollTimer = null;
      let pollInFlight = false;
      let lastShownErrorKey = &#39;&#39;;
      let pendingFocusTarget = &#39;&#39;;

      document.addEventListener(&#39;DOMContentLoaded&#39;, function() {
        bindEvents();
        refreshState(false);
      });

      function bindEvents() {
        document.getElementById(&#39;saveApiButton&#39;).addEventListener(&#39;click&#39;, saveApiKey);
        document.getElementById(&#39;changeApiButton&#39;).addEventListener(&#39;click&#39;, showApiKeyEditor);
        document.getElementById(&#39;deleteApiButton&#39;).addEventListener(&#39;click&#39;, deleteApiKey);
        document.getElementById(&#39;startButton&#39;).addEventListener(&#39;click&#39;, startCollection);
        document.getElementById(&#39;cancelButton&#39;).addEventListener(&#39;click&#39;, cancelCollection);

        document
          .getElementById(&#39;errorModalCloseButton&#39;)
          .addEventListener(&#39;click&#39;, closeErrorModal);

        document
          .getElementById(&#39;errorModalDismissButton&#39;)
          .addEventListener(&#39;click&#39;, closeErrorModal);

        document
          .getElementById(&#39;errorModalPrimaryButton&#39;)
          .addEventListener(&#39;click&#39;, handleErrorModalPrimaryAction);

        document
          .getElementById(&#39;errorModalBackdrop&#39;)
          .addEventListener(&#39;click&#39;, function(event) {
            if (event.target === event.currentTarget) {
              closeErrorModal();
            }
          });

        document.addEventListener(&#39;keydown&#39;, function(event) {
          if (event.key === &#39;Escape&#39;) {
            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, &#39;상태 확인&#39;);
            }
          })
          .getSidebarState();
      }

      function renderState(state) {
        currentState = state || {};

        const configured = Boolean(currentState.apiKeyConfigured);
        const running = Boolean(currentState.running);

        document.getElementById(&#39;apiMissingView&#39;).classList.toggle(&#39;hidden&#39;, configured);
        document.getElementById(&#39;apiSavedView&#39;).classList.toggle(&#39;hidden&#39;, !configured);
        document.getElementById(&#39;apiKeyMask&#39;).textContent = currentState.apiKeyMask || &#39;&#39;;

        document.getElementById(&#39;collectorFieldset&#39;).disabled = !configured || running;
        document.getElementById(&#39;lockedMessage&#39;).classList.toggle(&#39;hidden&#39;, configured);

        document.getElementById(&#39;changeApiButton&#39;).disabled = running;
        document.getElementById(&#39;deleteApiButton&#39;).disabled = running;

        renderProgress(currentState.progress, running);

        if (running) {
          startPolling();
        } else {
          stopPolling();
        }
      }

      function renderProgress(progress, running) {
        const card = document.getElementById(&#39;progressCard&#39;);

        if (!progress) {
          card.classList.add(&#39;hidden&#39;);
          return;
        }

        card.classList.remove(&#39;hidden&#39;);

        const status = progress.status || &#39;&#39;;
        const badge = document.getElementById(&#39;progressBadge&#39;);
        badge.textContent = progress.stage || status;
        badge.className = &#39;badge &#39; + getBadgeClass(status);

        const progressTitle =
          progress.videoTitle || progress.videoId || &#39;YouTube 영상&#39;;
        const outputSheetName = progress.outputSheetName || &#39;&#39;;

        document.getElementById(&#39;progressTitle&#39;).textContent =
          outputSheetName
            ? progressTitle + &#39; · &#39; + outputSheetName
            : progressTitle;

        document.getElementById(&#39;progressFill&#39;).style.width =
          String(progress.percent || 0) + &#39;%&#39;;

        document.getElementById(&#39;topMetric&#39;).textContent =
          formatNumber(progress.topCount) + &#39; / &#39; + formatNumber(progress.targetTopLevelCount);

        document.getElementById(&#39;replyMetric&#39;).textContent = formatNumber(progress.replyCount);
        document.getElementById(&#39;totalMetric&#39;).textContent = formatNumber(progress.totalRows);
        document.getElementById(&#39;apiCallMetric&#39;).textContent = formatNumber(progress.apiCalls);
        document.getElementById(&#39;progressMessage&#39;).textContent = progress.message || &#39;&#39;;
        document.getElementById(&#39;cancelButton&#39;).classList.toggle(&#39;hidden&#39;, !running);

        if (status === &#39;ERROR&#39;) {
          const errorInfo = progress.error || buildFallbackErrorInfo(
            progress.message || &#39;&#39;,
            &#39;댓글 수집&#39;
          );

          const errorKey = [
            errorInfo.code || &#39;UNKNOWN_ERROR&#39;,
            progress.updatedAt || &#39;&#39;,
            progress.videoId || &#39;&#39;
          ].join(&#39;|&#39;);

          if (errorKey !== lastShownErrorKey) {
            lastShownErrorKey = errorKey;
            showErrorModal(errorInfo);
          }
        }
      }

      function saveApiKey() {
        const input = document.getElementById(&#39;apiKey&#39;);
        const button = document.getElementById(&#39;saveApiButton&#39;);
        const apiKey = input.value.trim();

        if (!apiKey) {
          showErrorModal({
            code: &#39;API_KEY_REQUIRED&#39;,
            title: &#39;API 키를 입력해야 합니다.&#39;,
            summary: &#39;API 키 입력란이 비어 있어 검증을 시작할 수 없습니다.&#39;,
            causes: [
              &#39;입력란에 값이 입력되지 않았습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’에서 API 키를 확인합니다.&#39;,
              &#39;키 값을 복사하여 입력란에 붙여 넣습니다.&#39;,
              &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
            ],
            helpLabel: &#39;API 키 관리 화면 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;API 키 입력&#39;
          });
          return;
        }

        setButtonBusy(button, true, &#39;확인 중&#39;);
        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            input.value = &#39;&#39;;
            setButtonBusy(button, false, &#39;API 키 확인 후 저장&#39;);
            renderState(state);
            showMessage(&#39;API 키를 검증하여 저장했습니다.&#39;, &#39;success&#39;);
          })
          .withFailureHandler(function(error) {
            setButtonBusy(button, false, &#39;API 키 확인 후 저장&#39;);
            handleServerError(error, &#39;API 키 검증&#39;);
          })
          .saveApiKey(apiKey);
      }

      function showApiKeyEditor() {
        document.getElementById(&#39;apiSavedView&#39;).classList.add(&#39;hidden&#39;);
        document.getElementById(&#39;apiMissingView&#39;).classList.remove(&#39;hidden&#39;);
        document.getElementById(&#39;apiKey&#39;).focus();
        showMessage(&#39;새 API 키를 입력하면 기존 키가 교체됩니다.&#39;, &#39;info&#39;);
      }

      function deleteApiKey() {
        if (!window.confirm(&#39;저장된 API 키를 삭제하시겠습니까?&#39;)) {
          return;
        }

        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            renderState(state);
            showMessage(&#39;저장된 API 키를 삭제했습니다.&#39;, &#39;success&#39;);
          })
          .withFailureHandler(function(error) {
            handleServerError(error, &#39;API 키 삭제&#39;);
          })
          .deleteApiKey();
      }

      function startCollection() {
        const videoUrl = document.getElementById(&#39;videoUrl&#39;).value.trim();
        const limitText = document.getElementById(&#39;limit&#39;).value;
        const limit = Number(limitText);
        const startButton = document.getElementById(&#39;startButton&#39;);

        if (!videoUrl) {
          showErrorModal({
            code: &#39;VIDEO_URL_REQUIRED&#39;,
            title: &#39;영상 주소를 입력해야 합니다.&#39;,
            summary: &#39;수집 대상 YouTube 영상 주소가 비어 있습니다.&#39;,
            causes: [
              &#39;영상 주소 입력란에 값이 없습니다.&#39;
            ],
            steps: [
              &#39;YouTube에서 수집할 영상을 엽니다.&#39;,
              &#39;주소창의 전체 영상 주소를 복사하여 입력합니다.&#39;
            ],
            focusTarget: &#39;videoUrl&#39;,
            actionLabel: &#39;영상 주소 입력&#39;
          });
          return;
        }

        if (!Number.isInteger(limit) || limit &lt; 1 || limit &gt; 50000) {
          showErrorModal({
            code: &#39;COMMENT_LIMIT_INVALID&#39;,
            title: &#39;수집 개수를 확인해야 합니다.&#39;,
            summary: &#39;수집 개수는 1~50,000 사이의 정수로 입력해야 합니다.&#39;,
            causes: [
              &#39;빈 값, 소수, 문자 또는 허용 범위를 벗어난 수가 입력되었습니다.&#39;
            ],
            steps: [
              &#39;수집 개수 입력란에 1~50,000 사이의 정수를 입력합니다.&#39;,
              &#39;예: 100, 500, 1000&#39;
            ],
            focusTarget: &#39;limit&#39;,
            actionLabel: &#39;수집 개수 수정&#39;
          });
          return;
        }

        const payload = {
          videoUrl: videoUrl,
          limit: limit,
          includeReplies: document.getElementById(&#39;includeReplies&#39;).checked
        };

        setButtonBusy(startButton, true, &#39;준비 중&#39;);
        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            setButtonBusy(startButton, false, &#39;수집 시작&#39;);
            renderState(state);
            const sheetName =
              state &amp;&amp;
              state.progress &amp;&amp;
              state.progress.outputSheetName
                ? state.progress.outputSheetName
                : &#39;&#39;;

            showMessage(
              sheetName
                ? sheetName + &#39;에서 수집 작업을 시작했습니다.&#39;
                : &#39;수집 작업을 시작했습니다.&#39;,
              &#39;info&#39;
            );
            startPolling();

            // 서버에서 첫 수집 구간을 즉시 실행합니다.
            google.script.run
              .withSuccessHandler(function(updatedState) {
                renderState(updatedState);
              })
              .withFailureHandler(function(error) {
                refreshState(true);
                handleServerError(error, &#39;댓글 수집&#39;);
              })
              .runCollectionNow();
          })
          .withFailureHandler(function(error) {
            setButtonBusy(startButton, false, &#39;수집 시작&#39;);
            handleServerError(error, &#39;수집 준비&#39;);
          })
          .startCollectionFromSidebar(payload);
      }

      function cancelCollection() {
        if (!window.confirm(&#39;현재 수집 작업을 취소하시겠습니까? 이미 저장된 행은 유지됩니다.&#39;)) {
          return;
        }

        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            renderState(state);

            if (state &amp;&amp; state.running) {
              showMessage(&#39;취소 요청을 보냈습니다. 현재 API 페이지 처리가 끝나면 안전하게 중단됩니다.&#39;, &#39;info&#39;);
              startPolling();
            } else {
              showMessage(&#39;수집 작업을 취소했습니다.&#39;, &#39;success&#39;);
            }
          })
          .withFailureHandler(function(error) {
            handleServerError(error, &#39;수집 취소&#39;);
          })
          .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 = &#39;&lt;span class=&quot;row&quot; style=&quot;justify-content:center;&quot;&gt;&lt;span class=&quot;spinner&quot;&gt;&lt;/span&gt;&lt;span&gt;&#39; +
            escapeHtml(label) +
            &#39;&lt;/span&gt;&lt;/span&gt;&#39;;
        } else {
          button.textContent = label;
        }
      }

      function showMessage(text, type) {
        const box = document.getElementById(&#39;message&#39;);
        box.textContent = text || &#39;&#39;;
        box.className = &#39;message show &#39; + (type || &#39;info&#39;);
      }

      function clearMessage() {
        const box = document.getElementById(&#39;message&#39;);
        box.textContent = &#39;&#39;;
        box.className = &#39;message&#39;;
      }

      function getBadgeClass(status) {
        if (status === &#39;COMPLETED&#39;) {
          return &#39;success&#39;;
        }

        if (status === &#39;ERROR&#39; || status === &#39;CANCELLED&#39;) {
          return &#39;danger&#39;;
        }

        return &#39;warning&#39;;
      }

      function handleServerError(error, context) {
        const errorInfo = parseServerError(error, context);
        showErrorModal(errorInfo);
      }

      function parseServerError(error, context) {
        if (error &amp;&amp; error.title &amp;&amp; error.summary) {
          return normalizeClientErrorInfo(error);
        }

        const raw = error &amp;&amp; error.message
          ? String(error.message)
          : String(error || &#39;&#39;);

        const structured = extractStructuredError(raw);

        if (structured) {
          return normalizeClientErrorInfo(structured);
        }

        return buildFallbackErrorInfo(raw, context);
      }

      function extractStructuredError(rawText) {
        const raw = String(rawText || &#39;&#39;);
        const markerIndex = raw.indexOf(ERROR_PREFIX);

        if (markerIndex &lt; 0) {
          return null;
        }

        const objectStart = raw.indexOf(
          &#39;{&#39;,
          markerIndex + ERROR_PREFIX.length
        );

        if (objectStart &lt; 0) {
          return null;
        }

        let depth = 0;
        let inString = false;
        let escaped = false;

        for (let index = objectStart; index &lt; raw.length; index += 1) {
          const character = raw.charAt(index);

          if (inString) {
            if (escaped) {
              escaped = false;
            } else if (character === &#39;\\&#39;) {
              escaped = true;
            } else if (character === &#39;&quot;&#39;) {
              inString = false;
            }

            continue;
          }

          if (character === &#39;&quot;&#39;) {
            inString = true;
            continue;
          }

          if (character === &#39;{&#39;) {
            depth += 1;
          } else if (character === &#39;}&#39;) {
            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 || &#39;UNKNOWN_ERROR&#39;),
          title: String(info.title || &#39;작업을 완료하지 못했습니다.&#39;),
          summary: String(
            info.summary ||
            &#39;처리 중 예상하지 못한 문제가 발생했습니다.&#39;
          ),
          causes: Array.isArray(info.causes)
            ? info.causes.map(String)
            : [],
          steps: Array.isArray(info.steps)
            ? info.steps.map(String)
            : [],
          helpLabel: String(info.helpLabel || &#39;&#39;),
          helpUrl: String(info.helpUrl || &#39;&#39;),
          focusTarget: String(info.focusTarget || &#39;&#39;),
          actionLabel: String(info.actionLabel || &#39;확인&#39;),
          technical:
            info.technical &amp;&amp; typeof info.technical === &#39;object&#39;
              ? info.technical
              : {}
        };
      }

      function buildFallbackErrorInfo(rawText, context) {
        const raw = String(rawText || &#39;&#39;);
        const lower = raw.toLowerCase();
        const technical = {
          errorCode: &#39;UNCLASSIFIED_CLIENT_ERROR&#39;,
          context: String(context || &#39;작업 실행&#39;)
        };

        if (
          lower.indexOf(&#39;api key not valid&#39;) &gt;= 0 ||
          lower.indexOf(&#39;keyinvalid&#39;) &gt;= 0 ||
          lower.indexOf(&#39;api_key_invalid&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;API_KEY_INVALID&#39;,
            title: &#39;API 키를 사용할 수 없습니다.&#39;,
            summary: &#39;입력한 API 키가 정확하지 않거나 현재 사용할 수 없는 상태입니다.&#39;,
            causes: [
              &#39;키 일부가 누락되었거나 다른 값을 붙여 넣었을 수 있습니다.&#39;,
              &#39;Google Cloud에서 기존 키를 삭제하거나 재발급했을 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
              &#39;실제 API 키 값을 전체 복사합니다.&#39;,
              &#39;사이드바에서 키를 다시 입력하고 저장합니다.&#39;
            ],
            helpLabel: &#39;API 키 관리 화면 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;API 키 다시 입력&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;API_KEY_INVALID&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;access not configured&#39;) &gt;= 0 ||
          lower.indexOf(&#39;accessnotconfigured&#39;) &gt;= 0 ||
          lower.indexOf(&#39;has not been used in project&#39;) &gt;= 0 ||
          lower.indexOf(&#39;api_disabled&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;YOUTUBE_API_NOT_ENABLED&#39;,
            title: &#39;YouTube Data API v3가 활성화되어 있지 않습니다.&#39;,
            summary: &#39;API 키가 속한 Google Cloud 프로젝트에서 YouTube Data API v3 사용 설정을 켜야 합니다.&#39;,
            causes: [
              &#39;API 키를 만든 프로젝트에서 YouTube Data API v3를 아직 활성화하지 않았습니다.&#39;,
              &#39;API를 활성화한 프로젝트와 API 키를 발급한 프로젝트가 다를 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console에서 API 키를 만든 프로젝트를 선택합니다.&#39;,
              &#39;‘API 및 서비스 → 라이브러리’로 이동합니다.&#39;,
              &#39;‘YouTube Data API v3’를 검색하고 ‘사용’을 누릅니다.&#39;,
              &#39;설정 후 1~5분 뒤 API 키를 다시 저장합니다.&#39;
            ],
            helpLabel: &#39;YouTube Data API 설정 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/library/youtube.googleapis.com&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;설정 확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;YOUTUBE_API_NOT_ENABLED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;commentsdisabled&#39;) &gt;= 0 ||
          lower.indexOf(&#39;comments disabled&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;COMMENTS_DISABLED&#39;,
            title: &#39;이 영상에서는 댓글을 수집할 수 없습니다.&#39;,
            summary: &#39;영상 게시자가 댓글 기능을 사용하지 않도록 설정했습니다.&#39;,
            causes: [
              &#39;영상의 댓글 기능이 꺼져 있거나 YouTube 정책에 따라 제한되었습니다.&#39;
            ],
            steps: [
              &#39;영상 페이지에서 댓글 영역이 표시되는지 확인합니다.&#39;,
              &#39;댓글이 허용된 다른 영상 주소를 입력합니다.&#39;
            ],
            focusTarget: &#39;videoUrl&#39;,
            actionLabel: &#39;영상 주소 다시 입력&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;COMMENTS_DISABLED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;quotaexceeded&#39;) &gt;= 0 ||
          lower.indexOf(&#39;quota_exceeded&#39;) &gt;= 0 ||
          lower.indexOf(&#39;daily limit&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;YOUTUBE_QUOTA_EXCEEDED&#39;,
            title: &#39;YouTube API의 오늘 할당량을 모두 사용했습니다.&#39;,
            summary: &#39;현재 프로젝트에서 오늘 사용할 수 있는 YouTube Data API 할당량이 남아 있지 않습니다.&#39;,
            causes: [
              &#39;같은 프로젝트의 다른 작업에서도 YouTube API를 사용했을 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console에서 YouTube Data API의 오늘 사용량을 확인합니다.&#39;,
              &#39;일일 할당량이 갱신된 뒤 다시 실행합니다.&#39;,
              &#39;다음 수집에서는 요청 댓글 수를 줄입니다.&#39;
            ],
            helpLabel: &#39;YouTube API 할당량 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas&#39;,
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;YOUTUBE_QUOTA_EXCEEDED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;authorization is required&#39;) &gt;= 0 ||
          lower.indexOf(&#39;you do not have permission&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;,
            title: &#39;Google 권한 승인이 필요합니다.&#39;,
            summary: &#39;현재 계정이 스프레드시트 또는 외부 API 요청 권한을 아직 승인하지 않았습니다.&#39;,
            causes: [
              &#39;Apps Script를 처음 실행했거나 새로운 권한이 추가되었습니다.&#39;
            ],
            steps: [
              &#39;스프레드시트 메뉴에서 사이드바를 다시 엽니다.&#39;,
              &#39;권한 검토 화면에서 현재 계정을 선택합니다.&#39;,
              &#39;요청되는 권한을 허용한 뒤 다시 실행합니다.&#39;
            ],
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;timed out&#39;) &gt;= 0 ||
          lower.indexOf(&#39;maximum execution time&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;,
            title: &#39;한 번의 실행 가능 시간을 초과했습니다.&#39;,
            summary: &#39;댓글이 많아 현재 실행 구간이 종료되었습니다.&#39;,
            causes: [
              &#39;대량 댓글 또는 답글 처리 시간이 길어졌습니다.&#39;
            ],
            steps: [
              &#39;진행 상태가 자동으로 이어지는지 확인합니다.&#39;,
              &#39;멈춘 경우 ‘중단 지점부터 실행’을 한 번 실행합니다.&#39;,
              &#39;반복되면 수집 개수를 줄입니다.&#39;
            ],
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;
            })
          });
        }

        return normalizeClientErrorInfo({
          code: &#39;UNEXPECTED_ERROR&#39;,
          title: &#39;예상하지 못한 오류가 발생했습니다.&#39;,
          summary: &#39;오류 유형을 자동으로 판별하지 못했습니다. 입력값과 Apps Script 실행 기록을 확인해야 합니다.&#39;,
          causes: [
            &#39;네트워크 연결, Google 서비스 상태, 권한 또는 실행 환경에서 문제가 발생했을 수 있습니다.&#39;
          ],
          steps: [
            &#39;사이드바를 닫았다가 다시 열고 같은 작업을 한 번 더 실행합니다.&#39;,
            &#39;Apps Script 편집기의 왼쪽 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;,
            &#39;API 키 저장 상태와 영상 주소를 다시 확인합니다.&#39;
          ],
          actionLabel: &#39;확인&#39;,
          technical: technical
        });
      }

      function showErrorModal(errorInfo) {
        const info = normalizeClientErrorInfo(errorInfo);
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);
        const title = document.getElementById(&#39;errorModalTitle&#39;);
        const code = document.getElementById(&#39;errorModalCode&#39;);
        const summary = document.getElementById(&#39;errorModalSummary&#39;);
        const causeSection = document.getElementById(&#39;errorCauseSection&#39;);
        const stepSection = document.getElementById(&#39;errorStepSection&#39;);
        const causeList = document.getElementById(&#39;errorCauseList&#39;);
        const stepList = document.getElementById(&#39;errorStepList&#39;);
        const helpLink = document.getElementById(&#39;errorHelpLink&#39;);
        const primaryButton = document.getElementById(&#39;errorModalPrimaryButton&#39;);

        clearMessage();

        title.textContent = info.title;
        code.textContent = &#39;오류 코드: &#39; + info.code;
        summary.textContent = info.summary;

        renderTextList(causeList, info.causes);
        renderTextList(stepList, info.steps);

        causeSection.classList.toggle(&#39;hidden&#39;, info.causes.length === 0);
        stepSection.classList.toggle(&#39;hidden&#39;, info.steps.length === 0);

        if (
          info.helpUrl &amp;&amp;
          /^https:\/\/console\.cloud\.google\.com\//.test(info.helpUrl)
        ) {
          helpLink.href = info.helpUrl;
          helpLink.textContent = info.helpLabel || &#39;Google Cloud 설정 열기&#39;;
          helpLink.classList.remove(&#39;hidden&#39;);
        } else {
          helpLink.removeAttribute(&#39;href&#39;);
          helpLink.textContent = &#39;&#39;;
          helpLink.classList.add(&#39;hidden&#39;);
        }

        primaryButton.textContent = info.actionLabel || &#39;확인&#39;;
        pendingFocusTarget = info.focusTarget || &#39;&#39;;

        renderTechnicalInfo(info.technical);

        backdrop.classList.remove(&#39;hidden&#39;);
        document.body.style.overflow = &#39;hidden&#39;;

        window.setTimeout(function() {
          primaryButton.focus();
        }, 0);
      }

      function renderTextList(listElement, items) {
        listElement.innerHTML = &#39;&#39;;

        (items || []).forEach(function(item) {
          const listItem = document.createElement(&#39;li&#39;);
          listItem.textContent = String(item);
          listElement.appendChild(listItem);
        });
      }

      function renderTechnicalInfo(technical) {
        const details = document.getElementById(&#39;errorTechnicalDetails&#39;);
        const grid = document.getElementById(&#39;errorTechnicalGrid&#39;);
        const data = technical &amp;&amp; typeof technical === &#39;object&#39;
          ? technical
          : {};

        const labels = {
          errorCode: &#39;분류 코드&#39;,
          httpStatus: &#39;HTTP 상태&#39;,
          reason: &#39;API 사유 코드&#39;,
          resource: &#39;요청 자원&#39;,
          videoId: &#39;영상 ID&#39;,
          context: &#39;실행 단계&#39;
        };

        grid.innerHTML = &#39;&#39;;

        Object.keys(labels).forEach(function(key) {
          if (
            data[key] === undefined ||
            data[key] === null ||
            data[key] === &#39;&#39;
          ) {
            return;
          }

          const label = document.createElement(&#39;div&#39;);
          label.className = &#39;technical-label&#39;;
          label.textContent = labels[key];

          const value = document.createElement(&#39;div&#39;);
          value.className = &#39;technical-value&#39;;
          value.textContent = String(data[key]);

          grid.appendChild(label);
          grid.appendChild(value);
        });

        details.classList.toggle(&#39;hidden&#39;, grid.children.length === 0);
        details.open = false;
      }

      function closeErrorModal() {
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);

        if (backdrop.classList.contains(&#39;hidden&#39;)) {
          return;
        }

        backdrop.classList.add(&#39;hidden&#39;);
        document.body.style.overflow = &#39;&#39;;
        pendingFocusTarget = &#39;&#39;;
      }

      function handleErrorModalPrimaryAction() {
        const focusTarget = pendingFocusTarget;
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);

        backdrop.classList.add(&#39;hidden&#39;);
        document.body.style.overflow = &#39;&#39;;
        pendingFocusTarget = &#39;&#39;;

        focusErrorTarget(focusTarget);
      }

      function focusErrorTarget(targetId) {
        if (!targetId) {
          return;
        }

        if (targetId === &#39;apiKey&#39;) {
          document.getElementById(&#39;apiSavedView&#39;).classList.add(&#39;hidden&#39;);
          document.getElementById(&#39;apiMissingView&#39;).classList.remove(&#39;hidden&#39;);
        }

        const target = document.getElementById(targetId);

        if (target) {
          window.setTimeout(function() {
            target.focus();

            if (typeof target.select === &#39;function&#39;) {
              target.select();
            }
          }, 0);
        }
      }

      function formatNumber(value) {
        return Number(value || 0).toLocaleString(&#39;ko-KR&#39;);
      }

      function escapeHtml(value) {
        return String(value || &#39;&#39;)
          .replace(/&amp;/g, &#39;&amp;amp;&#39;)
          .replace(/&lt;/g, &#39;&amp;lt;&#39;)
          .replace(/&gt;/g, &#39;&amp;gt;&#39;)
          .replace(/&quot;/g, &#39;&amp;quot;&#39;)
          .replace(/&#39;/g, &#39;&amp;#039;&#39;);
      }
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</pre></section><section><h3>Youtube Data API v3 KEY</h3><pre>AIzaSyDETqF5gzbFh3DG4U0N-rjT1yK2asM-1X4</pre></section><h3>교안 및 학습자료</h3><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F1786407837256_1347027__%EA%B0%95%EC%9D%98_%EA%B9%80%EB%AF%BC%EA%B8%B0_AI_%EC%9C%B5%ED%95%A9_%EC%88%98%EC%97%85_%EC%84%A4%EA%B3%84.pdf?alt=media&amp;token=1348edb9-4175-4d67-86ad-e6a3dd0c347c">[강의] 김민기_AI 융합 수업 설계.pdf</a></li><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F1786420110600_921248__%EA%B0%95%EC%9D%98_%EA%B9%80%EB%AF%BC%EA%B8%B0_AI_%EC%9C%B5%ED%95%A9_%EC%88%98%EC%97%85_%EC%82%AC%EB%A1%80.pdf?alt=media&amp;token=6b5b5f0f-5e8f-47f0-bffe-2134ef7a6628">[강의] 김민기_AI 융합 수업 사례.pdf</a></li></ul><h3>관련 링크</h3><ul><li><a href="https://clovanote.naver.com/">네이버 클로바노트</a></li><li><a href="https://drive.google.com/file/d/1bTxR-DyK3I4i1ZRCdyVb_lWFEobrtOKe/view">음성 전사 실습</a></li><li><a href="https://brunch.co.kr/@mystoryg/156">Youtube Data API 키 발급 방법 안내</a></li></ul></section><section><h2>(4차시) 앱스스크립트</h2><h3>교안 및 학습자료</h3><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F1786407821295_1381233__%EA%B0%95%EC%9D%98_%EA%B9%80%EB%AF%BC%EA%B8%B0_%EC%95%B1%EC%8A%A4%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8.pdf?alt=media&amp;token=ea0fda47-2da3-4999-9b7f-c6aae8e7d841">[강의] 김민기_앱스스크립트.pdf</a></li></ul></section><section><h2>(5차시) 바른 API로 생기부 검토</h2><section><h3>Code.gs</h3><pre>// =================================================================
// [0] 초기 설정 및 메뉴
// =================================================================

function onOpen() {
  SpreadsheetApp.getUi().createMenu(&#39;민기쌤 생기부 도우미&#39;)
      .addItem(&#39;🚀 사이드바 열기&#39;, &#39;openSidebar&#39;) 
      .addToUi();
}

function renderView_(viewName) {
  var template = HtmlService.createTemplateFromFile(&#39;Sidebar&#39;);
  template.initialView = viewName || &#39;sidebar&#39;;
  return template.evaluate();
}

function openSidebar() {
  ensureBannedWordSheet_();
  var html = renderView_(&#39;sidebar&#39;)
      .setTitle(&#39;민기쌤 생기부 맞춤법 도우미&#39;)
      .setWidth(300); 
  SpreadsheetApp.getUi().showSidebar(html);
}

function openSpellCheckViewer() {
  var html = renderView_(&#39;spellcheck&#39;)
      .setWidth(1000) 
      .setHeight(700);
  SpreadsheetApp.getUi().showModelessDialog(html, &#39;📝 Bareun 맞춤법 검사 및 수정&#39;);
}

function openBannedWordViewer() {
  var html = renderView_(&#39;banned&#39;)
      .setWidth(1000)
      .setHeight(700);
  SpreadsheetApp.getUi().showModelessDialog(html, &#39;🚫 기재 금지어 점검&#39;);
}

// =================================================================
// [1] Bareun API 키 및 UI 설정
// =================================================================

function getBareunUserProperties_() {
  ensureBannedWordSheet_();
  var props = PropertiesService.getUserProperties();
  var currentPolicy = &#39;BAREUN_ONLY_KEYS_V3&#39;;
  var previousPolicy = props.getProperty(&#39;API_KEY_POLICY_VERSION&#39;);

  // 정책 변경 시 현재 기능에서 쓰지 않는 예전 사용자 설정을 한 번 정리합니다.
  if (previousPolicy !== currentPolicy) {
    var allowedProperties = {
      UI_STATE: true,
      API_KEY_POLICY_VERSION: true
    };
    // 바로 이전 사용자 입력형 정책에서 저장한 키만 보존합니다.
    if (previousPolicy === &#39;USER_SUPPLIED_KEYS_V1&#39; ||
        previousPolicy === &#39;BAREUN_ONLY_KEYS_V1&#39; ||
        previousPolicy === &#39;BAREUN_ONLY_KEYS_V2&#39;) {
      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(&#39;UI_STATE&#39;) || &#39;{}&#39;);
    } catch (e) {
      previousUiState = {};
    }
    var currentUiState = {};
    [&#39;card-api&#39;, &#39;sub-api-settings&#39;, &#39;card-util&#39;].forEach(function(id) {
      if (previousUiState[id]) currentUiState[id] = true;
    });
    props.setProperty(&#39;UI_STATE&#39;, JSON.stringify(currentUiState));
    props.setProperty(&#39;API_KEY_POLICY_VERSION&#39;, currentPolicy);
  }

  return props;
}

function saveUserBareunKey(bareunKey) {
  var props = getBareunUserProperties_();
  var cleanBareunKey = String(bareunKey || &#39;&#39;).trim();

  if (!cleanBareunKey) {
    throw new Error(&#39;Bareun API 키를 입력해 주세요. 이 도우미는 API 키를 제공하지 않습니다.&#39;);
  }

  props.setProperty(&#39;USER_BAREUN_KEY&#39;, cleanBareunKey);
  return { hasBareun: true };
}

function checkUserBareunStatus() {
  var props = getBareunUserProperties_();
  return { hasBareun: !!props.getProperty(&#39;USER_BAREUN_KEY&#39;) };
}

function clearUserBareunKey() {
  var props = getBareunUserProperties_();
  props.deleteProperty(&#39;USER_BAREUN_KEY&#39;);
  return { hasBareun: false };
}

function getBareunKey() {
  var key = getBareunUserProperties_().getProperty(&#39;USER_BAREUN_KEY&#39;);
  if (!key) throw new Error(&quot;Bareun API 키가 없습니다. 사이드바 상단에서 등록해주세요.&quot;);
  return key;
}

// =================================================================
// [2] Bareun 맞춤법 검사
// =================================================================

function getSelectedDataForSpellCheck() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var sheetName = sheet.getName();
  var range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);
  var rows = [];
  var startRow = range.getRow();
  var startCol = range.getColumn();
  var values = range.getValues();
  for (var i = 0; i &lt; values.length; i++) {
    for (var j = 0; j &lt; values[0].length; j++) {
      var text = values[i][j].toString();
      if (text.trim() !== &quot;&quot;) {
        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(&#39;검사한 시트를 찾을 수 없습니다: &#39; + item.sheetName);
    sheet.getRange(item.row, item.col).setValue(item.text);
  });
  return updates.length;
}

function callBareunApi(text, userKey) {
  var url = &quot;https://api.bareun.ai/bareun.RevisionService/CorrectError&quot;;
  var options = {
    &quot;method&quot;: &quot;post&quot;, &quot;contentType&quot;: &quot;application/json&quot;, &quot;headers&quot;: { &quot;api-key&quot;: userKey }, &quot;muteHttpExceptions&quot;: true,
    &quot;payload&quot;: JSON.stringify({ &quot;document&quot;: { &quot;content&quot;: text, &quot;language&quot;: &quot;ko-KR&quot; }, &quot;encoding_type&quot;: &quot;UTF16&quot; })
  };
  var response = UrlFetchApp.fetch(url, options);
  var result = JSON.parse(response.getContentText());
  
  if (response.getResponseCode() !== 200) {
    return { error: &quot;API 오류: &quot; + response.getResponseCode() };
  }
  return { revised: result.revised, helps: result.helps || {}, rawBlocks: result.revisedBlocks || result.revised_blocks || [], error: null };
}

// =================================================================
// [3] 기재 금지어 관리
// =================================================================

var BANNED_WORD_SHEET_NAME_ = &#39;기재금지어관리&#39;;
var BANNED_WORD_HEADERS_ = [&#39;번호&#39;, &#39;기재 금지어&#39;];
var LEGACY_BANNED_WORD_HEADERS_ = [&#39;유형&#39;, &#39;기재 금지어&#39;, &#39;대체 표현&#39;];
var ONE_COLUMN_BANNED_WORD_HEADERS_ = [&#39;기재 금지어&#39;];

function getLegacyBannedWordList_() {
  var saved = PropertiesService.getUserProperties().getProperty(&#39;BANNED_WORDS_DB&#39;);
  if (!saved) return getDefaultBannedList_();

  try {
    var parsed = JSON.parse(saved);
    if (!Array.isArray(parsed)) return getDefaultBannedList_();
    return parsed
      .map(function(item) {
        return String(item &amp;&amp; item.k || &#39;&#39;).trim();
      })
      .filter(function(keyword) { return keyword !== &#39;&#39;; });
  } catch (e) {
    return getDefaultBannedList_();
  }
}

function initializeBannedWordSheet_(sheet, list) {
  sheet.getRange(1, 1, 1, BANNED_WORD_HEADERS_.length)
    .setValues([BANNED_WORD_HEADERS_])
    .setBackground(&#39;#fce8e6&#39;)
    .setFontColor(&#39;#a50e0e&#39;)
    .setFontWeight(&#39;bold&#39;)
    .setHorizontalAlignment(&#39;center&#39;);

  var rows = list.map(function(keyword, index) {
    return [index + 1, keyword];
  });
  if (rows.length &gt; 0) sheet.getRange(2, 1, rows.length, 2).setValues(rows);

  sheet.setFrozenRows(1);
  sheet.setColumnWidth(1, 60);
  sheet.setColumnWidth(2, 260);
  sheet.setTabColor(&#39;#d93025&#39;);
}

function migrateLegacyBannedWordSheet_(sheet) {
  var lastColumn = sheet.getLastColumn();
  var legacyColumn;
  var clearColumns;

  if (lastColumn &gt;= 3 &amp;&amp; sheet.getRange(1, 1, 1, 3).getDisplayValues()[0].join(&#39;|&#39;) === LEGACY_BANNED_WORD_HEADERS_.join(&#39;|&#39;)) {
    legacyColumn = 2;
    clearColumns = 3;
  } else if (sheet.getRange(1, 1, 1, 1).getDisplayValues()[0][0] === ONE_COLUMN_BANNED_WORD_HEADERS_[0]) {
    legacyColumn = 1;
    clearColumns = 1;
  } else {
    return false;
  }

  var lastRow = sheet.getLastRow();
  var keywords = lastRow &lt; 2 ? [] : sheet.getRange(2, legacyColumn, lastRow - 1, 1)
    .getDisplayValues()
    .map(function(row) { return String(row[0] || &#39;&#39;).trim(); })
    .filter(function(keyword) { return keyword !== &#39;&#39;; });

  var clearRows = Math.max(sheet.getLastRow(), 1);
  sheet.getRange(1, 1, clearRows, clearColumns).clearContent();
  initializeBannedWordSheet_(sheet, keywords);
  return true;
}

function ensureBannedWordSheet_() {
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  if (!spreadsheet) throw new Error(&#39;활성 스프레드시트를 찾을 수 없습니다.&#39;);

  var originalSheet = spreadsheet.getActiveSheet();
  var originalRange = spreadsheet.getActiveRange();
  var lock = LockService.getDocumentLock() || LockService.getScriptLock();
  lock.waitLock(30000);

  var sheet;
  try {
    sheet = spreadsheet.getSheetByName(BANNED_WORD_SHEET_NAME_);
    if (!sheet) {
      sheet = spreadsheet.insertSheet(BANNED_WORD_SHEET_NAME_);
      initializeBannedWordSheet_(sheet, getLegacyBannedWordList_());
    } else {
      // 이전 형식은 금지어만 보존하여 번호·기재금지어 두 열 목록으로 안전하게 바꿉니다.
      migrateLegacyBannedWordSheet_(sheet);
    }

    // 시트로 옮긴 뒤 예전 사용자별 저장값은 더 이상 별도 원본으로 남기지 않습니다.
    PropertiesService.getUserProperties().deleteProperty(&#39;BANNED_WORDS_DB&#39;);
    return sheet;
  } finally {
    if (originalSheet &amp;&amp; sheet &amp;&amp; originalSheet.getSheetId() !== sheet.getSheetId()) {
      spreadsheet.setActiveSheet(originalSheet);
      if (originalRange) spreadsheet.setActiveRange(originalRange);
    }
    lock.releaseLock();
  }
}

function openBannedWordManagementSheet() {
  var sheet = ensureBannedWordSheet_();
  sheet.activate();
  sheet.getRange(&#39;A1&#39;).activate();
  return sheet.getName();
}

function getDefaultBannedList_() {
  return [
    &quot;MBTI&quot;, &quot;커리어넷&quot;, &quot;메이저맵&quot;, &quot;패들렛&quot;, &quot;띵커벨&quot;, &quot;알로&quot;,
    &quot;미리캔버스&quot;, &quot;망고보드&quot;, &quot;유튜버&quot;, &quot;유튜브&quot;, &quot;넷플릭스&quot;, &quot;왓챠&quot;,
    &quot;티빙&quot;, &quot;네이버 폼&quot;, &quot;구글 폼&quot;, &quot;OECD&quot;, &quot;UN&quot;, &quot;유네스코&quot;, &quot;WTO&quot;,
    &quot;WHO&quot;, &quot;그린피스&quot;, &quot;앰네스티&quot;, &quot;IMF&quot;, &quot;인스타그램&quot;, &quot;페이스북&quot;,
    &quot;카카오톡&quot;, &quot;네이버&quot;, &quot;구글&quot;, &quot;클래스 카드&quot;, &quot;구글 클래스룸&quot;, &quot;게더타운&quot;,
    &quot;제페토&quot;, &quot;아이패드&quot;, &quot;갤럭시 탭&quot;, &quot;비트코인&quot;, &quot;TED&quot;, &quot;반크&quot;, &quot;TOEIC&quot;,
    &quot;TOEFL&quot;, &quot;TEPS&quot;, &quot;대회&quot;, &quot;수상&quot;, &quot;한자능력검정&quot;
  ];
}

function getBannedWordList_() {
  var sheet = ensureBannedWordSheet_();
  var header = sheet.getRange(1, 1, 1, 2).getDisplayValues()[0];
  if (header.join(&#39;|&#39;) !== BANNED_WORD_HEADERS_.join(&#39;|&#39;)) {
    throw new Error(&#39;기재금지어관리 시트의 1행은 &quot;번호 | 기재 금지어&quot; 순서여야 합니다.&#39;);
  }

  var lastRow = sheet.getLastRow();
  if (lastRow &lt; 2) return [];

  var values = sheet.getRange(2, 1, lastRow - 1, 2).getDisplayValues();
  var seen = {};
  var list = [];

  values.forEach(function(row, index) {
    var rowNumber = index + 2;
    var number = String(row[0] || &#39;&#39;).trim();
    var keyword = String(row[1] || &#39;&#39;).trim();

    if (!number &amp;&amp; !keyword) return;
    if (!/^[1-9]\d*$/.test(number)) {
      throw new Error(&#39;기재금지어관리 시트 &#39; + rowNumber + &#39;행의 번호(A열)는 1 이상의 정수여야 합니다.&#39;);
    }
    if (!keyword) throw new Error(&#39;기재금지어관리 시트 &#39; + rowNumber + &#39;행의 기재 금지어(B열)를 입력해 주세요.&#39;);

    var duplicateKey = normalizeBannedWord_(keyword);
    if (seen[duplicateKey]) {
      throw new Error(&#39;기재금지어관리 시트 &#39; + rowNumber + &#39;행의 기재 금지어가 &#39; + seen[duplicateKey] + &#39;행과 중복됩니다: &#39; + keyword);
    }
    seen[duplicateKey] = rowNumber;
    list.push(keyword);
  });

  return list;
}

function runBannedWordCheck() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var sheetName = sheet.getName();
  var range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);

  var values = range.getValues();
  var startRow = range.getRow();
  var startCol = range.getColumn();
  
  var db = getBannedWordList_();
  if (db.length === 0) return [];
  db.sort(function(a, b) { return normalizeBannedWord_(b).length - normalizeBannedWord_(a).length; });
  var canonicalByNormalizedKeyword = {};
  db.forEach(function(keyword) { canonicalByNormalizedKeyword[normalizeBannedWord_(keyword)] = keyword; });
  var combinedPattern = new RegExp(&#39;(&#39; + db.map(buildWhitespaceInsensitivePattern_).join(&#39;|&#39;) + &#39;)&#39;, &#39;gi&#39;);

  var results = [];

  for (var i = 0; i &lt; values.length; i++) {
    for (var j = 0; j &lt; values[0].length; j++) {
      var original = values[i][j].toString();
      if (!original.trim()) continue;

      var hasIssue = false;
      var logs = [];

      var counts = {};
      var match;
      while ((match = combinedPattern.exec(original)) !== null) {
        var normalizedMatch = normalizeBannedWord_(match[0]);
        var keyword = canonicalByNormalizedKeyword[normalizedMatch];
        if (keyword) {
          hasIssue = true;
          counts[keyword] = (counts[keyword] || 0) + 1;
        }
      }

      Object.keys(counts).forEach(function(keyword) {
        logs.push({ k: keyword, count: counts[keyword] });
      });

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

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

function normalizeBannedWord_(value) {
  return String(value || &#39;&#39;).replace(/\s+/g, &#39;&#39;).toLocaleLowerCase();
}

function buildWhitespaceInsensitivePattern_(keyword) {
  var compact = normalizeBannedWord_(keyword);
  if (!compact) throw new Error(&#39;비어 있는 기재 금지어는 검사할 수 없습니다.&#39;);
  return compact.split(&#39;&#39;).map(function(character) {
    return escapeRegExp(character);
  }).join(&#39;[\\s\\u00A0]*&#39;);
}

// =================================================================
// [4] 로컬 검토 유틸리티
// =================================================================

function unifyToStraightQuotes() {
  var range = SpreadsheetApp.getActiveRange();
  var ui = SpreadsheetApp.getUi();
  if (!range) throw new Error(&#39;선택된 셀이 없습니다.&#39;);
  var response = ui.alert(&#39;따옴표 변환&#39;, &#39;선택 영역의 둥근 따옴표를 곧은 따옴표로 변환하시겠습니까?&#39;, ui.ButtonSet.YES_NO);
  if (response != ui.Button.YES) return;
  try {
    range.createTextFinder(&#39;[‘’]&#39;).useRegularExpression(true).matchEntireCell(false).replaceAllWith(&quot;&#39;&quot;);
    range.createTextFinder(&#39;[“”]&#39;).useRegularExpression(true).matchEntireCell(false).replaceAllWith(&#39;&quot;&#39;);
    SpreadsheetApp.flush();
    ui.alert(&#39;✅ 변환 완료&#39;);
  } catch (e) { ui.alert(&#39;오류: &#39; + e.toString()); }
}

// [신규] 선택 영역 스페이스바 실수(2칸 이상) 수정 함수
function fixDoubleSpaces() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);

  const values = range.getValues();
  let fixCount = 0; // 수정된 셀 개수 카운트

  const newValues = values.map(row =&gt; {
    return row.map(cell =&gt; {
      let str = String(cell);
      // 공백이 2번 이상 연속( /  +/)으로 있으면 1번으로 변경
      // 줄바꿈(\n)은 건드리지 않도록 일반 공백( )만 타겟팅
      if (/ {2,}/.test(str)) {
        fixCount++;
        return str.replace(/ {2,}/g, &#39; &#39;); 
      }
      return cell;
    });
  });

  if (fixCount &gt; 0) {
    range.setValues(newValues);
  }
  
  return fixCount;
}

// =================================================================
// [5] UI 상태 저장/불러오기
// =================================================================

function saveUiState(cardId, isExpanded) {
  var props = PropertiesService.getUserProperties();
  var state = JSON.parse(props.getProperty(&#39;UI_STATE&#39;) || &#39;{}&#39;);
  state[cardId] = isExpanded;
  props.setProperty(&#39;UI_STATE&#39;, JSON.stringify(state));
}

function getUiState() {
  var props = PropertiesService.getUserProperties();
  return JSON.parse(props.getProperty(&#39;UI_STATE&#39;) || &#39;{}&#39;);
}
</pre></section><section><h3>Sidebar.html</h3><pre>&lt;? var allowedViews = [&quot;sidebar&quot;, &quot;spellcheck&quot;, &quot;banned&quot;]; ?&gt;
&lt;? var activeView = allowedViews.indexOf(initialView) &gt;= 0 ? initialView : &quot;sidebar&quot;; ?&gt;
&lt;? if (activeView === &#39;sidebar&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;style&gt;
      * { box-sizing: border-box; }
      body {
        margin: 0;
        padding: 10px 15px 10px 2px;
        background: #f1f3f4;
        color: #202124;
        font-family: &#39;Noto Sans KR&#39;, 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=&quot;password&quot;] {
        width: 100%;
        padding: 10px;
        border: 2px solid #dadce0;
        border-radius: 6px;
        outline: none;
        font-family: inherit;
        font-size: 14px;
      }
      input[type=&quot;password&quot;]: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; }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div class=&quot;card&quot; id=&quot;card-api&quot;&gt;
      &lt;div class=&quot;card-header&quot; onclick=&quot;toggleCard(this)&quot;&gt;
        &lt;div class=&quot;header-title&quot;&gt;🔑 Bareun API 키 설정&lt;/div&gt;
        &lt;span class=&quot;material-icons toggle-icon&quot;&gt;expand_more&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class=&quot;card-body&quot;&gt;
        &lt;div class=&quot;sub-section&quot; id=&quot;sub-api-settings&quot;&gt;
          &lt;div class=&quot;sub-header&quot; onclick=&quot;toggleSub(this)&quot;&gt;
            &lt;span&gt;내 Bareun API 키&lt;/span&gt;
            &lt;span class=&quot;material-icons sub-icon&quot;&gt;expand_more&lt;/span&gt;
          &lt;/div&gt;
          &lt;div class=&quot;sub-body&quot;&gt;
            &lt;div id=&quot;api-loading&quot; class=&quot;api-status&quot;&gt;⌛ API 키 설정 확인 중...&lt;/div&gt;
            &lt;div id=&quot;api-settings&quot; class=&quot;hidden&quot;&gt;
              &lt;div id=&quot;apiStatusSummary&quot; class=&quot;api-status&quot;&gt;&lt;/div&gt;
              &lt;label class=&quot;input-label&quot; for=&quot;bareunKeyInput&quot;&gt;Bareun API 키&lt;/label&gt;
              &lt;input type=&quot;password&quot; id=&quot;bareunKeyInput&quot; autocomplete=&quot;off&quot; placeholder=&quot;본인이 발급받은 키를 입력하세요&quot;&gt;
              &lt;div class=&quot;key-help&quot;&gt;공용 키는 제공하지 않습니다. 키는 사용자별 설정에 저장되고 원문을 화면으로 다시 불러오지 않습니다.&lt;/div&gt;
              &lt;button class=&quot;btn-auth&quot; id=&quot;saveKeyBtn&quot; onclick=&quot;saveBareunKey()&quot;&gt;내 Bareun 키 저장&lt;/button&gt;
              &lt;button class=&quot;btn-secondary hidden&quot; id=&quot;clearKeyBtn&quot; onclick=&quot;resetBareunKey()&quot;&gt;저장된 Bareun 키 삭제&lt;/button&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;card expanded&quot; id=&quot;card-util&quot;&gt;
      &lt;div class=&quot;card-header&quot; onclick=&quot;toggleCard(this)&quot;&gt;
        &lt;div class=&quot;header-title&quot;&gt;📝 선택 영역 검사 및 정리&lt;/div&gt;
        &lt;span class=&quot;material-icons toggle-icon&quot;&gt;expand_more&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class=&quot;card-body&quot;&gt;
        &lt;button class=&quot;btn-warning&quot; id=&quot;checkBtn&quot; onclick=&quot;runSpellCheck()&quot;&gt;선택 영역 Bareun 맞춤법 검사 🔍&lt;/button&gt;
        &lt;div id=&quot;checkStatus&quot; class=&quot;status-box&quot;&gt;&lt;/div&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;runSpaceFix(this)&quot;&gt;선택 영역 연속 공백 정리&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;convertQuotes(this)&quot;&gt;선택 영역 따옴표 통일 (&#39; &quot;)&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;runBannedCheck()&quot;&gt;선택 영역 기재 금지어 검사&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;openBannedWordManagement()&quot;&gt;기재금지어관리 시트 열기&lt;/button&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let currentApiStatus = { hasBareun: false };

      function updateApiState(status) {
        currentApiStatus = { hasBareun: !!(status &amp;&amp; status.hasBareun) };

        const summary = document.getElementById(&#39;apiStatusSummary&#39;);
        const clearButton = document.getElementById(&#39;clearKeyBtn&#39;);
        const spellCheckButton = document.getElementById(&#39;checkBtn&#39;);
        const apiCard = document.getElementById(&#39;card-api&#39;);
        const apiSub = document.getElementById(&#39;sub-api-settings&#39;);

        summary.innerHTML = currentApiStatus.hasBareun
          ? &#39;&lt;div class=&quot;ok&quot;&gt;✅ Bareun 키 저장됨&lt;/div&gt;&#39;
          : &#39;&lt;div class=&quot;missing&quot;&gt;❌ 맞춤법 검사를 사용하려면 Bareun 키가 필요합니다.&lt;/div&gt;&#39;;
        clearButton.classList.toggle(&#39;hidden&#39;, !currentApiStatus.hasBareun);
        spellCheckButton.disabled = !currentApiStatus.hasBareun;
        spellCheckButton.title = currentApiStatus.hasBareun ? &#39;&#39; : &#39;Bareun API 키를 먼저 저장해 주세요.&#39;;

        apiCard.classList.toggle(&#39;expanded&#39;, !currentApiStatus.hasBareun);
        apiSub.classList.toggle(&#39;expanded&#39;, !currentApiStatus.hasBareun);
      }

      function toggleCard(header) {
        const card = header.parentElement;
        const isExpanded = card.classList.toggle(&#39;expanded&#39;);
        google.script.run.saveUiState(card.id, isExpanded);
      }

      function toggleSub(header) {
        const section = header.parentElement;
        const isExpanded = section.classList.toggle(&#39;expanded&#39;);
        google.script.run.saveUiState(section.id, isExpanded);
      }

      function saveBareunKey() {
        const button = document.getElementById(&#39;saveKeyBtn&#39;);
        const keyInput = document.getElementById(&#39;bareunKeyInput&#39;);
        const key = keyInput.value.trim();
        if (!key) {
          alert(&#39;Bareun API 키를 입력해 주세요.&#39;);
          return;
        }

        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;저장 중...&#39;;

        google.script.run
          .withSuccessHandler(function(status) {
            keyInput.value = &#39;&#39;;
            updateApiState(status);
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;✅ Bareun API 키를 사용자 설정에 저장했습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;Bareun API 키 저장 오류: &#39; + error);
          })
          .saveUserBareunKey(key);
      }

      function resetBareunKey() {
        if (!confirm(&#39;저장된 Bareun API 키를 삭제하시겠습니까?&#39;)) return;

        google.script.run
          .withSuccessHandler(function(status) {
            document.getElementById(&#39;bareunKeyInput&#39;).value = &#39;&#39;;
            updateApiState(status);
            alert(&#39;저장된 Bareun API 키를 삭제했습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            alert(&#39;Bareun API 키 삭제 오류: &#39; + error);
          })
          .clearUserBareunKey();
      }

      function runSpellCheck() {
        if (!currentApiStatus.hasBareun) {
          alert(&#39;Bareun API 키를 먼저 저장해 주세요.&#39;);
          return;
        }
        document.getElementById(&#39;checkStatus&#39;).innerText = &#39;맞춤법 검사 창을 여는 중...&#39;;
        google.script.run
          .withSuccessHandler(function() {
            document.getElementById(&#39;checkStatus&#39;).innerText = &#39;&#39;;
          })
          .withFailureHandler(function(error) {
            document.getElementById(&#39;checkStatus&#39;).innerText = &#39;&#39;;
            alert(&#39;오류 발생: &#39; + error);
          })
          .openSpellCheckViewer();
      }

      function runSpaceFix(button) {
        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;정리 중...&#39;;
        google.script.run
          .withSuccessHandler(function(count) {
            button.disabled = false;
            button.innerText = originalText;
            alert(count ? &#39;✅ 총 &#39; + count + &#39;개 셀의 연속 공백을 정리했습니다.&#39; : &#39;수정할 연속 공백이 없습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;오류 발생: &#39; + error);
          })
          .fixDoubleSpaces();
      }

      function convertQuotes(button) {
        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;변환 중...&#39;;
        google.script.run
          .withSuccessHandler(function() {
            button.disabled = false;
            button.innerText = originalText;
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;오류 발생: &#39; + error);
          })
          .unifyToStraightQuotes();
      }

      function runBannedCheck() {
        google.script.run
          .withFailureHandler(function(error) { alert(&#39;오류 발생: &#39; + error); })
          .openBannedWordViewer();
      }

      function openBannedWordManagement() {
        google.script.run
          .withFailureHandler(function(error) { alert(&#39;오류 발생: &#39; + error); })
          .openBannedWordManagementSheet();
      }

      window.onload = function() {
        google.script.run
          .withSuccessHandler(function(status) {
            document.getElementById(&#39;api-loading&#39;).classList.add(&#39;hidden&#39;);
            document.getElementById(&#39;api-settings&#39;).classList.remove(&#39;hidden&#39;);
            updateApiState(status);

            google.script.run.withSuccessHandler(function(states) {
              Object.keys(states || {}).forEach(function(id) {
                const element = document.getElementById(id);
                if (id === &#39;card-api&#39; || id === &#39;sub-api-settings&#39;) return;
                if (element &amp;&amp; states[id]) element.classList.add(&#39;expanded&#39;);
              });
              updateApiState(status);
            }).getUiState();
          })
          .withFailureHandler(function(error) {
            document.getElementById(&#39;api-loading&#39;).classList.add(&#39;hidden&#39;);
            document.getElementById(&#39;api-settings&#39;).classList.remove(&#39;hidden&#39;);
            updateApiState({ hasBareun: false });
            document.getElementById(&#39;apiStatusSummary&#39;).textContent = &#39;설정 확인 오류: &#39; + error;
          })
          .checkUserBareunStatus();
      };
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } else if (activeView === &#39;spellcheck&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch.js&quot;&gt;&lt;/script&gt;
    &lt;style&gt;
      body { padding: 20px; font-family: &#39;Noto Sans KR&#39;, 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; }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;

    &lt;div class=&quot;header-area&quot;&gt;
      &lt;div class=&quot;title&quot;&gt;&lt;span class=&quot;material-icons&quot;&gt;fact_check&lt;/span&gt; 맞춤법 검사 및 수정&lt;/div&gt;
      &lt;div style=&quot;font-size:12px; color:#5f6368; display:flex; gap:15px;&quot;&gt;
        &lt;div&gt;&lt;span style=&quot;background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;&quot;&gt;원본 오류&lt;/span&gt;&lt;/div&gt;
        &lt;div&gt;&lt;span style=&quot;background:#e6f4ea; color:#188038; border-bottom:2px solid #188038; padding:0 4px; font-weight:500;&quot;&gt;수정 제안&lt;/span&gt;&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div id=&quot;mainContent&quot; class=&quot;content-area&quot;&gt;
      &lt;div id=&quot;loaderOverlay&quot; class=&quot;loader-overlay&quot;&gt;
        &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
        &lt;div id=&quot;loadingText&quot;&gt;데이터를 불러오는 중입니다...&lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;cardsContainer&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;footer-bar&quot;&gt;
      &lt;button class=&quot;btn btn-cancel&quot; onclick=&quot;google.script.host.close()&quot;&gt;닫기&lt;/button&gt;
      &lt;button class=&quot;btn btn-apply&quot; id=&quot;btnApply&quot; onclick=&quot;applyAll()&quot; disabled&gt;적용하기 (0건)&lt;/button&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let itemData = []; 
      let isFirstRender = true; 

      window.onload = function() {
        google.script.run
          .withSuccessHandler(processData)
          .withFailureHandler(function(err) {
             document.getElementById(&#39;loaderOverlay&#39;).innerHTML = 
               `&lt;div class=&quot;error-msg&quot;&gt;❌ 초기화 오류&lt;br&gt;${err}&lt;br&gt;선택된 셀이 있는지 확인해주세요.&lt;/div&gt;`;
          })
          .getSelectedDataForSpellCheck();
      };

      function processData(rows) {
        if (rows.length === 0) {
          document.getElementById(&#39;loaderOverlay&#39;).innerHTML = &#39;&lt;div class=&quot;error-msg&quot;&gt;선택된 셀이 없거나 빈 셀입니다.&lt;/div&gt;&#39;;
          return;
        }

        document.getElementById(&#39;loadingText&#39;).innerText = `총 ${rows.length}개 항목 분석 중...`;
        const container = document.getElementById(&#39;cardsContainer&#39;);
        container.innerHTML = &quot;&quot;; 
        
        let processedCount = 0;
        const total = rows.length;

        rows.forEach((row, index) =&gt; {
          const div = document.createElement(&#39;div&#39;);
          div.id = `item-${index}`;
          div.className = &#39;check-item&#39;;
          div.innerHTML = `
            &lt;div class=&quot;item-header&quot;&gt;&lt;span&gt;행 ${row.row} (대기 중...)&lt;/span&gt;&lt;/div&gt;
            &lt;div class=&quot;split-view&quot;&gt;&lt;div class=&quot;pane pane-left&quot;&gt;...&lt;/div&gt;&lt;div class=&quot;pane pane-right&quot;&gt;...&lt;/div&gt;&lt;/div&gt;
          `;
          container.appendChild(div);

          google.script.run
            .withSuccessHandler((res) =&gt; {
              renderResult(index, row, res);
              processedCount++;
              updateApplyButton(processedCount, total);
            })
            .withFailureHandler((err) =&gt; {
              renderError(index, row, err);
              processedCount++;
              updateApplyButton(processedCount, total);
            })
            .getBareunCorrection(row.original);
        });
      }

      function renderError(index, rowData, errorMsg) {
        if (isFirstRender) {
          document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
          isFirstRender = false;
        }
        const itemDiv = document.getElementById(`item-${index}`);
        if (!itemDiv) return;
        itemDiv.innerHTML = `&lt;div style=&quot;padding:20px; color:red;&quot;&gt;❌ 분석 실패: ${errorMsg}&lt;/div&gt;`;
      }

      function renderResult(index, rowData, apiResult) {
        if (isFirstRender) {
          document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
          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 = &quot;&quot;;
        const analysisList = [];
        if (apiResult.rawBlocks &amp;&amp; apiResult.rawBlocks.length &gt; 0) {
          traverseBlocks(apiResult.rawBlocks, apiResult.helps, analysisList);
        }

        if (analysisList.length &gt; 0) {
          const listItems = analysisList.map(item =&gt; `
            &lt;li class=&quot;analysis-row&quot;&gt;
              &lt;span class=&quot;err-word&quot;&gt;${item.origin}&lt;/span&gt;
              &lt;span class=&quot;arrow&quot;&gt;→&lt;/span&gt;
              &lt;span class=&quot;fix-word&quot;&gt;${item.revised}&lt;/span&gt;
              &lt;span class=&quot;fix-reason&quot;&gt;${item.msg}&lt;/span&gt;
            &lt;/li&gt;
          `).join(&quot;&quot;);

          helpListHtml = `
            &lt;div class=&quot;analysis-box&quot;&gt;
              &lt;div class=&quot;analysis-title&quot;&gt;&lt;span class=&quot;material-icons&quot; style=&quot;font-size:14px;&quot;&gt;info&lt;/span&gt; 상세 수정 내역 (${analysisList.length}건)&lt;/div&gt;
              &lt;ul class=&quot;analysis-list&quot;&gt;${listItems}&lt;/ul&gt;
            &lt;/div&gt;
          `;
        }

        itemDiv.innerHTML = `
          &lt;div class=&quot;item-header&quot;&gt;
            &lt;span&gt;행 ${rowData.row}&lt;/span&gt;
            &lt;span style=&quot;color:#1a73e8;&quot;&gt;분석 완료&lt;/span&gt;
          &lt;/div&gt;
          &lt;div class=&quot;split-view&quot;&gt;
            &lt;div class=&quot;pane pane-left&quot;&gt;${diff.left}&lt;/div&gt;
            &lt;div class=&quot;pane pane-right&quot; contenteditable=&quot;true&quot; spellcheck=&quot;false&quot; id=&quot;edit-${index}&quot; oninput=&quot;updateBytes(${index})&quot;&gt;${diff.right}&lt;/div&gt;
          &lt;/div&gt;
          ${helpListHtml}
          &lt;div class=&quot;byte-info&quot;&gt;
            &lt;div class=&quot;byte-box&quot; style=&quot;border-right:1px solid #eee;&quot;&gt;원본: ${getByteLength(rowData.original)} byte&lt;/div&gt;
            &lt;div class=&quot;byte-box&quot;&gt;수정본: &lt;span id=&quot;byte-${index}&quot; class=&quot;byte-val&quot;&gt;${getByteLength(apiResult.revised)}&lt;/span&gt; byte&lt;/div&gt;
          &lt;/div&gt;
        `;
        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 &amp;&amp; block.nested.length &gt; 0) {
            traverseBlocks(block.nested, helps, resultsArray);
          } else {
            const origin = block.origin.content || &quot;&quot;;
            const revised = block.revised || &quot;&quot;;
            if (origin === revised) return;
            let msg = &quot;교정&quot;;
            if (block.revisions &amp;&amp; block.revisions.length &gt; 0) {
              const helpId = block.revisions[0].helpId || block.revisions[0].help_id;
              if (helps &amp;&amp; !helps[helpId]) return;
              if (helpId &amp;&amp; helps &amp;&amp; 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 === &#39;undefined&#39;) {
           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 = &quot;&quot;;
          for (var i = 0; i &lt; 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 = &quot;&quot;;
        let rightHtml = &quot;&quot;;
        
        diffs.forEach(function(part) {
          var op = part[0]; 
          var chars = part[1];
          var text = &quot;&quot;;
          
          // 유니코드를 다시 원래 단어로 복원
          for (var i = 0; i &lt; chars.length; i++) {
            text += tokenArr[chars.charCodeAt(i)];
          }
          
          if (op === 0) {
            leftHtml += text; rightHtml += text;
          } else if (op === -1) {
            leftHtml += `&lt;span class=&quot;diff-del&quot;&gt;${text}&lt;/span&gt;`;
          } else if (op === 1) {
            rightHtml += `&lt;span class=&quot;diff-add&quot;&gt;${text}&lt;/span&gt;`;
          }
        });
        
        return { left: leftHtml, right: rightHtml };
      }

      function getByteLength(s) {
        let b = 0; if (!s) return 0;
        for (let i = 0; i &lt; s.length; i++) {
          const c = s.charCodeAt(i);
          if (c === 10) b += 2; else if (c &gt;&gt; 11) b += 3; else if (c &gt;&gt; 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(&#39;btnApply&#39;);
        btn.innerText = `적용하기 (${current} / ${total})`;
        if (current === total) btn.disabled = false;
      }

      function applyAll() {
        if (!confirm(&quot;검사된 내용으로 원본 데이터를 덮어쓰시겠습니까?\n(되돌릴 수 없습니다)&quot;)) return;
        const updates = [];
        itemData.forEach((item, index) =&gt; {
          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(&#39;btnApply&#39;);
        btn.disabled = true; btn.innerText = &quot;저장 중...&quot;;
        google.script.run.withSuccessHandler((count) =&gt; {
          alert(`✅ 총 ${count}건이 수정되었습니다.`);
          google.script.host.close();
        }).applySpellCheckResults(updates);
      }
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } else if (activeView === &#39;banned&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;style&gt;
      body { padding: 20px; font-family: &#39;Noto Sans KR&#39;, 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: &quot;•&quot;; 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); } }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;

    &lt;div class=&quot;header-area&quot;&gt;
      &lt;div class=&quot;title&quot;&gt;&lt;span class=&quot;material-icons&quot;&gt;block&lt;/span&gt; 기재 금지어 검사&lt;/div&gt;
      &lt;div id=&quot;checkLegend&quot; style=&quot;font-size:12px; color:#5f6368; display:flex; gap:15px;&quot;&gt;
        &lt;div&gt;&lt;span style=&quot;background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;&quot;&gt;금지어 발견&lt;/span&gt;&lt;/div&gt;
        &lt;div&gt;오른쪽 수정안에서 문맥에 맞게 직접 고쳐 주세요.&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div id=&quot;checkView&quot; class=&quot;content-area&quot;&gt;
      &lt;div id=&quot;loaderOverlay&quot; class=&quot;loader-overlay&quot;&gt;
        &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
        &lt;div id=&quot;loadingText&quot;&gt;데이터를 분석 중입니다...&lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;cardsContainer&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;footer-bar&quot; id=&quot;footerCheck&quot;&gt;
      &lt;button class=&quot;btn btn-cancel&quot; onclick=&quot;google.script.host.close()&quot;&gt;닫기&lt;/button&gt;
      &lt;button class=&quot;btn btn-apply&quot; id=&quot;btnApply&quot; onclick=&quot;applyAll()&quot; disabled&gt;적용하기 (0건)&lt;/button&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let itemData = []; 
      let renderQueue = [];
      const CHUNK_SIZE = 20;

      window.onload = function() {
        runCheck();
      };

      function runCheck() {
        document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;flex&#39;;
        document.getElementById(&#39;loadingText&#39;).innerText = &quot;데이터를 분석 중입니다...&quot;;
        document.getElementById(&#39;cardsContainer&#39;).innerHTML = &quot;&quot;;
        
        google.script.run
          .withSuccessHandler(startRenderSequence)
          .withFailureHandler(err =&gt; alert(&quot;오류: &quot; + err))
          .runBannedWordCheck();
      }

      function startRenderSequence(rows) {
        document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
        const container = document.getElementById(&#39;cardsContainer&#39;);
        
        if (!rows || rows.length === 0) {
          container.innerHTML = &#39;&lt;div style=&quot;text-align:center; padding:50px; color:#5f6368;&quot;&gt;금지어가 발견되지 않았습니다. 🎉&lt;/div&gt;&#39;;
          document.getElementById(&#39;btnApply&#39;).disabled = true;
          return;
        }

        itemData = rows; 
        renderQueue = [...rows]; 
        processRenderQueue(); 

        document.getElementById(&#39;btnApply&#39;).innerText = `적용하기 (${rows.length}건)`;
        document.getElementById(&#39;btnApply&#39;).disabled = false;
      }

      function processRenderQueue() {
        if (renderQueue.length === 0) return;

        const container = document.getElementById(&#39;cardsContainer&#39;);
        const chunk = renderQueue.splice(0, CHUNK_SIZE);
        
        chunk.forEach((row) =&gt; {
          const originalIndex = itemData.findIndex(r =&gt; r.row === row.row &amp;&amp; r.col === row.col);
          let logHtml = &quot;&quot;;
          if (row.logs &amp;&amp; row.logs.length &gt; 0) {
            const listItems = row.logs.map(log =&gt; {
               const count = log.count &gt; 1 ? ` (${log.count}회)` : &#39;&#39;;
               return `&lt;li class=&quot;analysis-row&quot;&gt;&lt;span&gt;&lt;span class=&quot;err-word&quot;&gt;${escapeHtml(log.k)}&lt;/span&gt;${count} 기재금지어가 발견되었습니다. 오른쪽 수정안에서 문맥에 맞게 직접 고쳐 주세요.&lt;/span&gt;&lt;/li&gt;`;
            }).join(&quot;&quot;);
            
            logHtml = `
              &lt;div class=&quot;analysis-box&quot;&gt;
                &lt;ul class=&quot;analysis-list&quot;&gt;${listItems}&lt;/ul&gt;
              &lt;/div&gt;
            `;
          }

          const div = document.createElement(&#39;div&#39;);
          div.className = &#39;check-item&#39;;
          div.innerHTML = `
            &lt;div class=&quot;item-header&quot;&gt;
              &lt;span&gt;행 ${row.row}&lt;/span&gt;
              &lt;span style=&quot;color:#d93025; font-weight:bold;&quot;&gt;금지어 발견&lt;/span&gt;
            &lt;/div&gt;
            &lt;div class=&quot;split-view&quot;&gt;
              &lt;div class=&quot;pane pane-left&quot;&gt;${highlightBannedWords(row.original, row.logs)}&lt;/div&gt;
              &lt;div class=&quot;pane pane-right&quot; contenteditable=&quot;true&quot; spellcheck=&quot;false&quot; id=&quot;edit-${originalIndex}&quot;&gt;&lt;/div&gt;
            &lt;/div&gt;
            ${logHtml}
          `;
          container.appendChild(div);
          // 원문을 그대로 넣습니다. 기재금지어 검사는 자동 대체·삭제를 전혀 하지 않습니다.
          document.getElementById(`edit-${originalIndex}`).textContent = row.original;
        });

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

      function escapeHtml(value) {
        return String(value)
          .replace(/&amp;/g, &#39;&amp;amp;&#39;)
          .replace(/&lt;/g, &#39;&amp;lt;&#39;)
          .replace(/&gt;/g, &#39;&amp;gt;&#39;)
          .replace(/&quot;/g, &#39;&amp;quot;&#39;)
          .replace(/&#39;/g, &#39;&amp;#39;&#39;);
      }

      function escapeRegExp(value) {
        return String(value).replace(/[.*+?^${}()|[\]\\]/g, &#39;\\$&amp;&#39;);
      }

      function highlightBannedWords(text, logs) {
        const keywords = (logs || []).map(log =&gt; log.k).filter(Boolean);
        if (keywords.length === 0) return escapeHtml(text);
        const fragments = keywords
          .sort((a, b) =&gt; compactBannedWord(b).length - compactBannedWord(a).length)
          .map(buildWhitespaceInsensitivePattern);
        const pattern = new RegExp(`(${fragments.join(&#39;|&#39;)})`, &#39;gi&#39;);
        return String(text).split(pattern).map((part, index) =&gt; {
          const safeText = escapeHtml(part);
          return index % 2 === 1 ? `&lt;span class=&quot;diff-del&quot;&gt;${safeText}&lt;/span&gt;` : safeText;
        }).join(&#39;&#39;);
      }

      function compactBannedWord(value) {
        return String(value || &#39;&#39;).replace(/\s+/g, &#39;&#39;).toLocaleLowerCase();
      }

      function buildWhitespaceInsensitivePattern(keyword) {
        return compactBannedWord(keyword)
          .split(&#39;&#39;)
          .map(escapeRegExp)
          .join(&#39;[\\s\\u00A0]*&#39;);
      }

      function applyAll() {
        if (!confirm(&quot;수정된 내용으로 원본을 덮어쓰시겠습니까?&quot;)) return;
        
        const updates = [];
        itemData.forEach((item, index) =&gt; {
          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) =&gt; {
           alert(`${c}건 수정 완료!`);
           google.script.host.close();
        }).applySpellCheckResults(updates);
      }

    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } ?&gt;
</pre></section><h3>관련 링크</h3><ul><li><a href="https://nara-speller.co.kr/speller/">하나씩 검사하기 - 바른한글</a></li><li><a href="https://bareun.ai/api-key">API로 대량 검사하기 - 바른AI</a></li></ul></section>]]></description></item><item><title>AIEDAP 마스터 교원 전문성 강화 연수 - 교과 기반(국어 심화)</title><link>https://민기.한국/69</link><guid isPermaLink="true">https://민기.한국/69</guid><pubDate>Sun, 09 Aug 2026 04:08:37 GMT</pubDate><description><![CDATA[<h1>AIEDAP 마스터 교원 전문성 강화 연수 - 교과 기반(국어 심화)</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-08-09</dd><dt>장소</dt><dd>공주대학교</dd></dl><p>AIEDAP 마스터 교원 대상 전문성 강화 연수 강의입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F978605_%EA%B0%95%EC%9D%98%EC%9E%90%EB%A3%8C_%EA%B9%80%EB%AF%BC%EA%B8%B0.pdf?alt=media&amp;token=be77003f-cc40-4b9b-a2c7-9be8823dec69">강의자료_김민기.pdf</a></li><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F22394_%EA%B5%AD%EC%96%B4%EA%B3%BC_%EC%8B%AC%ED%99%94_%EC%82%B0%EC%B6%9C%EB%AC%BC_%EC%84%B1%ED%95%A8.docx?alt=media&amp;token=8fa8bc54-4218-4fde-8395-1e0ea42e7bcb">국어과_심화_산출물_성함.docx</a></li></ul></section><section><h2>관련 링크</h2><ul><li><a href="https://짧은.한국/산출물수합">산출물 수합</a></li><li><a href="https://짧은.한국/만족도조사">만족도 조사</a></li></ul></section><section><h2>유튜브 댓글 데이터 분석</h2><section><h3>Code.gs</h3><pre>const YT_CFG = Object.freeze({
  API_BASE: &#39;https://www.googleapis.com/youtube/v3&#39;,

  API_KEY_PROP: &#39;YT_API_KEY&#39;,
  STATE_PROP: &#39;YT_COMMENT_COLLECTOR_STATE&#39;,
  LAST_RESULT_PROP: &#39;YT_COMMENT_COLLECTOR_LAST_RESULT&#39;,
  CANCEL_REQUEST_PROP: &#39;YT_COMMENT_COLLECTOR_CANCEL_REQUEST&#39;,

  OUTPUT_SHEET_PREFIX: &#39;시트&#39;,
  INTERNAL_SHEET: &#39;_댓글수집_내부&#39;,
  LEGACY_LOG_SHEET: &#39;수집로그&#39;,

  CONTINUE_HANDLER: &#39;continueCommentCollection&#39;,
  STRUCTURED_ERROR_PREFIX: &#39;YT_STRUCTURED_ERROR::&#39;,
  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: [
    &#39;번호&#39;,
    &#39;채널명&#39;,
    &#39;영상 제목&#39;,
    &#39;댓글 작성자&#39;,
    &#39;댓글 내용&#39;,
    &#39;좋아요 수&#39;,
    &#39;작성 시각&#39;,
    &#39;글자수&#39;
  ],

  // 숨김 내부 시트의 열 구조입니다.
  INTERNAL_HEADERS: [
    &#39;comment_id&#39;,
    &#39;parent_comment_id&#39;,
    &#39;display_number&#39;,
    &#39;comment_type&#39;,
    &#39;reply_status&#39;,
    &#39;next_reply_index&#39;,
    &#39;total_reply_count&#39;,
    &#39;reply_page_token&#39;
  ],

  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(&#39;YouTube 댓글 수집&#39;)
    .addItem(&#39;사이드바 열기&#39;, &#39;showYouTubeCommentSidebar&#39;)
    .addSeparator()
    .addItem(&#39;중단 지점부터 실행&#39;, &#39;continueCommentCollection&#39;)
    .addItem(&#39;진행 중인 수집 취소&#39;, &#39;cancelCommentCollection&#39;)
    .addToUi();
}


/**
 * 오른쪽 사이드바를 엽니다.
 */
function showYouTubeCommentSidebar() {
  const html = HtmlService
    .createHtmlOutputFromFile(&#39;Sidebar&#39;)
    .setTitle(&#39;YouTube 댓글 수집기&#39;);

  SpreadsheetApp.getUi().showSidebar(html);
}


/**
 * 사이드바 초기 상태를 반환합니다.
 */
function getSidebarState() {
  const props = getUserProperties_();
  const apiKey = props.getProperty(YT_CFG.API_KEY_PROP) || &#39;&#39;;
  const activeState = loadState_();
  const lastResult = loadLastResult_();
  const cancelRequested =
    props.getProperty(YT_CFG.CANCEL_REQUEST_PROP) === &#39;true&#39;;

  const progress = activeState
    ? buildProgressForClient_(activeState, &#39;RUNNING&#39;)
    : lastResult
      ? buildProgressForClient_(lastResult, lastResult.status || &#39;COMPLETED&#39;)
      : null;

  if (progress &amp;&amp; activeState &amp;&amp; cancelRequested) {
    progress.stage = &#39;취소 처리 중&#39;;
    progress.message = &#39;현재 실행 구간이 끝나는 즉시 안전하게 중단됩니다.&#39;;
  }

  return {
    apiKeyConfigured: Boolean(apiKey),
    apiKeyMask: apiKey ? maskApiKey_(apiKey) : &#39;&#39;,
    running: Boolean(activeState),
    cancelRequested: cancelRequested,
    progress: progress
  };
}


/**
 * API 키를 실제 YouTube Data API 요청으로 검증한 뒤 사용자별 속성에 저장합니다.
 */
function saveApiKey(apiKey) {
  const cleanedKey = String(apiKey || &#39;&#39;).trim();

  if (!cleanedKey) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_REQUIRED&#39;,
      &#39;API 키를 입력해야 합니다.&#39;,
      &#39;Google Cloud에서 발급한 YouTube Data API 키가 입력되지 않았습니다.&#39;,
      [&#39;API 키 입력란이 비어 있습니다.&#39;],
      [
        &#39;Google Cloud Console에서 API 키를 발급합니다.&#39;,
        &#39;API 키 전체를 사이드바 입력란에 붙여 넣습니다.&#39;,
        &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 입력&#39;
      }
    ));
  }

  if (cleanedKey.length &lt; 20) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_FORMAT_INVALID&#39;,
      &#39;API 키 형식을 확인해야 합니다.&#39;,
      &#39;입력한 문자열이 일반적인 Google API 키 길이보다 짧습니다.&#39;,
      [
        &#39;API 키 일부만 복사했을 수 있습니다.&#39;,
        &#39;프로젝트 ID나 다른 값을 입력했을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;키 유형이 ‘API 키’인지 확인합니다.&#39;,
        &#39;API 키 값을 처음부터 끝까지 다시 복사하여 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 다시 입력&#39;
      }
    ));
  }

  validateApiKey_(cleanedKey);

  const props = getUserProperties_();
  props.setProperty(YT_CFG.API_KEY_PROP, cleanedKey);

  // 이전 API 키 오류 안내가 새 키 저장 뒤 다시 나타나지 않도록 정리합니다.
  const previous = loadLastResult_();
  if (previous &amp;&amp; previous.status === &#39;ERROR&#39;) {
    props.deleteProperty(YT_CFG.LAST_RESULT_PROP);
  }

  return getSidebarState();
}


/**
 * 저장된 API 키를 삭제합니다.
 */
function deleteApiKey() {
  if (loadState_()) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_DELETE_BLOCKED&#39;,
      &#39;수집 중에는 API 키를 삭제할 수 없습니다.&#39;,
      &#39;현재 댓글 수집 작업이 저장된 API 키를 사용하고 있습니다.&#39;,
      [&#39;진행 중인 작업이 존재합니다.&#39;],
      [
        &#39;현재 수집을 완료하거나 취소합니다.&#39;,
        &#39;수집 상태가 종료된 뒤 API 키를 삭제합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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_(
        &#39;SPREADSHEET_NOT_FOUND&#39;,
        &#39;댓글을 저장할 스프레드시트를 찾지 못했습니다.&#39;,
        &#39;현재 Apps Script가 스프레드시트와 연결된 상태인지 확인해야 합니다.&#39;,
        [
          &#39;독립 실행형 Apps Script 프로젝트에서 코드를 실행했을 수 있습니다.&#39;,
          &#39;스프레드시트가 닫히거나 접근 권한이 변경되었을 수 있습니다.&#39;
        ],
        [
          &#39;댓글을 저장할 Google 스프레드시트를 엽니다.&#39;,
          &#39;‘확장 프로그램 → Apps Script’에서 이 코드를 실행합니다.&#39;,
          &#39;스프레드시트를 새로고침하고 사이드바를 다시 엽니다.&#39;
        ],
        { actionLabel: &#39;확인&#39; }
      ));
    }

    // 영상과 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: &#39;RUNNING&#39;,
      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: &#39;&#39;,
      threadDone: false,

      currentReplyParentId: &#39;&#39;,
      nextReplyPageToken: &#39;&#39;,

      topCount: 0,
      replyCount: 0,
      threadPages: 0,
      replyPages: 0,
      apiCalls: 1,

      startedAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      message: outputSheet.getName() + &#39;에서 수집을 시작했습니다.&#39;,
      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 &lt; 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 || &#39;&#39;;
        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 = &#39;실행 시간을 고려하여 진행 상태를 저장했습니다.&#39;;
    saveState_(state);
    scheduleContinuation_();

  } catch (error) {
    deleteContinuationTriggers_();

    const errorInfo = normalizeErrorInfo_(error, &#39;댓글 수집&#39;);

    if (errorInfo.code === &#39;API_KEY_INVALID&#39;) {
      getUserProperties_().deleteProperty(YT_CFG.API_KEY_PROP);
    }

    if (commentsSheet) {
      try {
        finalizeCommentsSheet_(commentsSheet);
      } catch (formatError) {
        console.error(formatError);
      }
    }

    if (state) {
      state.status = &#39;ERROR&#39;;
      state.updatedAt = new Date().toISOString();
      state.message = errorInfo.summary;
      state.error = errorInfo;
      saveLastResult_(state, &#39;ERROR&#39;, 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, &#39;true&#39;);
  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 &lt;= 0) {
    state.threadDone = true;
    state.nextThreadPageToken = &#39;&#39;;
    return;
  }

  const params = {
    part: state.includeReplies ? &#39;snippet,replies&#39; : &#39;snippet&#39;,
    videoId: state.videoId,
    maxResults: Math.min(100, remaining),
    textFormat: &#39;plainText&#39;
  };

  if (state.nextThreadPageToken) {
    params.pageToken = state.nextThreadPageToken;
  }

  const data = youtubeGet_(&#39;commentThreads&#39;, 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 &gt;= 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 = &#39;DONE&#39;;
    let nextReplyIndex = 1;

    commentRows.push(
      commentToOutputRow_(state, topComment, topNumber)
    );

    batchIds.add(topCommentId);
    topAdded += 1;

    const inlineReplies =
      thread.replies &amp;&amp; Array.isArray(thread.replies.comments)
        ? thread.replies.comments
        : [];

    if (state.includeReplies &amp;&amp; totalReplyCount &gt; 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 + &#39;_&#39; + nextReplyIndex;
          nextReplyIndex += 1;

          commentRows.push(
            commentToOutputRow_(state, reply, displayNumber)
          );

          internalRows.push(
            buildInternalRow_(
              replyId,
              topCommentId,
              displayNumber,
              &#39;reply&#39;,
              &#39;&#39;,
              &#39;&#39;,
              &#39;&#39;,
              &#39;&#39;
            )
          );

          batchIds.add(replyId);
          replyAdded += 1;
        });
      } else {
        // commentThreads.list의 replies에는 전체 답글이 보장되지 않으므로
        // comments.list로 전체 답글을 다시 가져옵니다.
        replyStatus = &#39;PENDING&#39;;
        nextReplyIndex = 1;
      }
    }

    internalRows.push(
      buildInternalRow_(
        topCommentId,
        &#39;&#39;,
        topNumber,
        &#39;topLevel&#39;,
        replyStatus,
        nextReplyIndex,
        totalReplyCount,
        &#39;&#39;
      )
    );
  });

  appendCollectedRecords_(
    commentsSheet,
    internalSheet,
    commentRows,
    internalRows
  );

  batchIds.forEach(function(id) {
    existingIds.add(id);
  });

  state.topCount += topAdded;
  state.replyCount += replyAdded;

  const reachedTarget = state.topCount &gt;= state.targetTopLevelCount;
  const hasNextPage = Boolean(data.nextPageToken);

  state.nextThreadPageToken =
    !reachedTarget &amp;&amp; hasNextPage
      ? data.nextPageToken
      : &#39;&#39;;

  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 = &#39;&#39;;
    state.nextReplyPageToken = &#39;&#39;;
    return;
  }

  const params = {
    part: &#39;snippet&#39;,
    parentId: state.currentReplyParentId,
    maxResults: 100,
    textFormat: &#39;plainText&#39;
  };

  const pageToken =
    state.nextReplyPageToken || topRecord.replyPageToken || &#39;&#39;;

  if (pageToken) {
    params.pageToken = pageToken;
  }

  const data = youtubeGet_(&#39;comments&#39;, 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) + &#39;_&#39; + nextReplyIndex;

    nextReplyIndex += 1;

    commentRows.push(
      commentToOutputRow_(state, reply, displayNumber)
    );

    internalRows.push(
      buildInternalRow_(
        replyId,
        state.currentReplyParentId,
        displayNumber,
        &#39;reply&#39;,
        &#39;&#39;,
        &#39;&#39;,
        &#39;&#39;,
        &#39;&#39;
      )
    );

    batchIds.add(replyId);
    replyAdded += 1;
  });

  appendCollectedRecords_(
    commentsSheet,
    internalSheet,
    commentRows,
    internalRows
  );

  batchIds.forEach(function(id) {
    existingIds.add(id);
  });

  state.replyCount += replyAdded;

  const nextPageToken = data.nextPageToken || &#39;&#39;;
  const nextStatus = nextPageToken ? &#39;IN_PROGRESS&#39; : &#39;DONE&#39;;

  updateTopReplyProgress_(
    internalSheet,
    topRecord.rowNumber,
    nextStatus,
    nextReplyIndex,
    nextPageToken
  );

  if (nextPageToken) {
    state.nextReplyPageToken = nextPageToken;
  } else {
    state.currentReplyParentId = &#39;&#39;;
    state.nextReplyPageToken = &#39;&#39;;
  }

  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(&#39;IN_PROGRESS&#39;)
    .matchEntireCell(true)
    .useRegularExpression(false)
    .findNext();

  if (!found) {
    found = statusRange
      .createTextFinder(&#39;PENDING&#39;)
      .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] === &#39;PENDING&#39;) {
    internalSheet
      .getRange(rowNumber, YT_CFG.INTERNAL_COL.REPLY_STATUS)
      .setValue(&#39;IN_PROGRESS&#39;);
  }

  return {
    rowNumber: rowNumber,
    commentId: String(values[YT_CFG.INTERNAL_COL.COMMENT_ID - 1] || &#39;&#39;),
    displayNumber: String(
      values[YT_CFG.INTERNAL_COL.DISPLAY_NUMBER - 1] || &#39;&#39;
    ),
    nextReplyIndex: Number(
      values[YT_CFG.INTERNAL_COL.NEXT_REPLY_INDEX - 1] || 1
    ),
    pageToken: String(
      values[YT_CFG.INTERNAL_COL.REPLY_PAGE_TOKEN - 1] || &#39;&#39;
    )
  };
}


/**
 * 댓글 1건을 사용자가 보는 8열 행으로 변환합니다.
 */
function commentToOutputRow_(state, comment, displayNumber) {
  const snippet = (comment &amp;&amp; comment.snippet) || {};
  const text = String(snippet.textDisplay || &#39;&#39;);

  return [
    String(displayNumber),
    safeCellText_(state.videoChannelTitle || &#39;&#39;),
    safeCellText_(state.videoTitle || &#39;&#39;),
    safeCellText_(snippet.authorDisplayName || &#39;&#39;),
    safeCellText_(text),
    Number(snippet.likeCount || 0),
    apiDateToSheetValue_(snippet.publishedAt),
    calculateCommentBytes_(text)
  ];
}


/**
 * 숨김 내부 시트의 행을 만듭니다.
 */
function buildInternalRow_(
  commentId,
  parentCommentId,
  displayNumber,
  commentType,
  replyStatus,
  nextReplyIndex,
  totalReplyCount,
  replyPageToken
) {
  return [
    safeCellText_(commentId || &#39;&#39;),
    safeCellText_(parentCommentId || &#39;&#39;),
    String(displayNumber || &#39;&#39;),
    String(commentType || &#39;&#39;),
    String(replyStatus || &#39;&#39;),
    nextReplyIndex === &#39;&#39; ? &#39;&#39; : Number(nextReplyIndex || 1),
    totalReplyCount === &#39;&#39; ? &#39;&#39; : Number(totalReplyCount || 0),
    safeCellText_(replyPageToken || &#39;&#39;)
  ];
}


/**
 * 댓글 행과 내부 관리 행을 각각 일괄 기록합니다.
 */
function appendCollectedRecords_(
  commentsSheet,
  internalSheet,
  commentRows,
  internalRows
) {
  if (commentRows.length !== internalRows.length) {
    throwStructuredError_(buildErrorInfo_(
      &#39;INTERNAL_ROW_MISMATCH&#39;,
      &#39;댓글 저장 구조를 확인해야 합니다.&#39;,
      &#39;댓글 행과 내부 관리 행의 개수가 일치하지 않습니다.&#39;,
      [&#39;수집 상태를 기록하는 내부 데이터가 일시적으로 어긋났습니다.&#39;],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;사이드바에서 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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 &lt;= 1) {
    return null;
  }

  const found = internalSheet
    .getRange(2, YT_CFG.INTERNAL_COL.COMMENT_ID, lastRow - 1, 1)
    .createTextFinder(String(commentId || &#39;&#39;))
    .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] || &#39;&#39;),
    displayNumber: String(
      values[YT_CFG.INTERNAL_COL.DISPLAY_NUMBER - 1] || &#39;&#39;
    ),
    replyStatus: String(
      values[YT_CFG.INTERNAL_COL.REPLY_STATUS - 1] || &#39;&#39;
    ),
    nextReplyIndex: Number(
      values[YT_CFG.INTERNAL_COL.NEXT_REPLY_INDEX - 1] || 1
    ),
    replyPageToken: String(
      values[YT_CFG.INTERNAL_COL.REPLY_PAGE_TOKEN - 1] || &#39;&#39;
    )
  };
}


/**
 * 최상위 댓글의 답글 수집 진행 정보를 갱신합니다.
 */
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 || &#39;&#39;
    ]]);
}


/**
 * 완료 처리
 */
function finishCollection_(ss, commentsSheet, state) {
  state.status = &#39;COMPLETED&#39;;
  state.updatedAt = new Date().toISOString();
  state.finishedAt = new Date().toISOString();
  state.message = state.topCount &lt; state.targetTopLevelCount
    ? &#39;댓글 &#39; +
      state.topCount +
      &#39;개와 대댓글 &#39; +
      state.replyCount +
      &#39;개를 수집했습니다.&#39;
    : &#39;댓글 수집을 완료했습니다.&#39;;
  state.error = null;

  finalizeCommentsSheet_(commentsSheet);
  saveLastResult_(state, &#39;COMPLETED&#39;, 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) === &#39;true&#39;;
}


/**
 * 취소 처리
 */
function finalizeCancellation_(ss, commentsSheet, state) {
  const message = &#39;사용자가 수집을 취소했습니다. 이미 저장된 댓글은 유지됩니다.&#39;;

  state.status = &#39;CANCELLED&#39;;
  state.updatedAt = new Date().toISOString();
  state.finishedAt = new Date().toISOString();
  state.message = message;
  state.error = null;

  finalizeCommentsSheet_(commentsSheet);
  saveLastResult_(state, &#39;CANCELLED&#39;, 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_(
    &#39;videos&#39;,
    {
      part: &#39;id&#39;,
      id: &#39;jNQXAC9IVRw&#39;
    },
    apiKey
  );
}


/**
 * 영상 메타데이터를 조회합니다.
 */
function getVideoMetadataWithKey_(videoId, apiKey) {
  const data = youtubeGetWithKey_(
    &#39;videos&#39;,
    {
      part: &#39;snippet,statistics&#39;,
      id: videoId
    },
    apiKey
  );

  if (!Array.isArray(data.items) || data.items.length === 0) {
    throwStructuredError_(buildErrorInfo_(
      &#39;VIDEO_NOT_FOUND&#39;,
      &#39;영상 정보를 찾을 수 없습니다.&#39;,
      &#39;입력한 영상 주소에 해당하는 공개 영상을 YouTube Data API에서 조회하지 못했습니다.&#39;,
      [
        &#39;영상 주소나 11자리 영상 ID가 정확하지 않을 수 있습니다.&#39;,
        &#39;영상이 삭제되었거나 비공개 상태일 수 있습니다.&#39;,
        &#39;연령·지역·계정 권한에 따라 접근이 제한된 영상일 수 있습니다.&#39;
      ],
      [
        &#39;브라우저에서 해당 영상 주소가 정상적으로 열리는지 확인합니다.&#39;,
        &#39;주소 표시줄의 영상 URL 전체를 다시 복사합니다.&#39;,
        &#39;사이드바의 영상 주소를 교체한 뒤 수집을 다시 시작합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: { videoId: videoId }
      }
    ));
  }

  const item = data.items[0];
  const snippet = item.snippet || {};
  const statistics = item.statistics || {};

  return {
    title: snippet.title || &#39;&#39;,
    channelId: snippet.channelId || &#39;&#39;,
    channelTitle: snippet.channelTitle || &#39;&#39;,
    commentCount: statistics.commentCount || &#39;&#39;
  };
}


/**
 * 저장된 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 &amp;&amp; value !== null &amp;&amp; value !== &#39;&#39;;
    })
    .map(function(key) {
      return encodeURIComponent(key) + &#39;=&#39; + encodeURIComponent(queryParams[key]);
    })
    .join(&#39;&amp;&#39;);

  const url = YT_CFG.API_BASE + &#39;/&#39; + resource + &#39;?&#39; + query;
  const transientCodes = [429, 500, 502, 503, 504];

  for (let attempt = 0; attempt &lt; 3; attempt += 1) {
    let response;

    try {
      response = UrlFetchApp.fetch(url, {
        method: &#39;get&#39;,
        muteHttpExceptions: true,
        headers: { Accept: &#39;application/json&#39; }
      });
    } catch (networkError) {
      if (attempt &lt; 2) {
        Utilities.sleep(Math.pow(2, attempt) * 1000);
        continue;
      }

      throwStructuredError_(buildErrorInfo_(
        &#39;NETWORK_REQUEST_FAILED&#39;,
        &#39;YouTube 서버에 연결하지 못했습니다.&#39;,
        &#39;Apps Script에서 YouTube Data API로 보내는 외부 요청이 완료되지 않았습니다.&#39;,
        [
          &#39;Google 서비스 또는 네트워크에 일시적인 장애가 발생했을 수 있습니다.&#39;,
          &#39;Apps Script의 외부 요청 권한이 승인되지 않았을 수 있습니다.&#39;
        ],
        [
          &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
          &#39;Apps Script 권한 승인 화면이 나타나면 요청된 권한을 허용합니다.&#39;,
          &#39;Apps Script의 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;
        ],
        {
          actionLabel: &#39;확인&#39;,
          technical: {
            resource: resource,
            originalMessage: String(networkError &amp;&amp; networkError.message || networkError)
          }
        }
      ));
    }

    const statusCode = response.getResponseCode();
    const responseText = response.getContentText();

    if (statusCode &gt;= 200 &amp;&amp; statusCode &lt; 300) {
      return responseText ? JSON.parse(responseText) : {};
    }

    if (transientCodes.indexOf(statusCode) &gt;= 0 &amp;&amp; attempt &lt; 2) {
      Utilities.sleep(Math.pow(2, attempt) * 1000);
      continue;
    }

    const parsedError = parseYouTubeErrorResponse_(responseText);

    throwStructuredError_(classifyYouTubeApiError_(
      statusCode,
      parsedError.reason,
      parsedError.message,
      resource
    ));
  }

  throwStructuredError_(buildErrorInfo_(
    &#39;YOUTUBE_REQUEST_REPEATED_FAILURE&#39;,
    &#39;YouTube API 요청이 반복적으로 실패했습니다.&#39;,
    &#39;일시적인 서버 장애가 재시도 후에도 계속되었습니다.&#39;,
    [&#39;YouTube 또는 Google 네트워크 서비스가 불안정할 수 있습니다.&#39;],
    [
      &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
      &#39;반복되면 Apps Script의 ‘실행’ 기록을 확인합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: { resource: resource } }
  ));
}


function parseYouTubeErrorResponse_(responseText) {
  let reason = &#39;&#39;;
  let message = String(responseText || &#39;&#39;);

  try {
    const parsed = JSON.parse(responseText);

    if (parsed.error) {
      message = parsed.error.message || message;

      if (Array.isArray(parsed.error.errors) &amp;&amp; parsed.error.errors[0]) {
        reason = parsed.error.errors[0].reason || &#39;&#39;;
      }

      if (!reason &amp;&amp; parsed.error.status) {
        reason = parsed.error.status;
      }
    }
  } catch (parseError) {
    // JSON이 아닌 오류 본문은 문자열 그대로 분류합니다.
  }

  return {
    reason: String(reason || &#39;&#39;),
    message: String(message || &#39;&#39;)
  };
}


/**
 * YouTube API 오류를 한국어 모달용 정보로 분류합니다.
 */
function classifyYouTubeApiError_(statusCode, reason, message, resource) {
  const reasonText = String(reason || &#39;&#39;);
  const messageText = String(message || &#39;&#39;);
  const combined = (reasonText + &#39; &#39; + messageText).toLowerCase();
  const technical = {
    httpStatus: statusCode,
    apiReason: reasonText,
    resource: resource,
    originalMessage: messageText
  };

  if (
    combined.indexOf(&#39;keyinvalid&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api key not valid&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api_key_invalid&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_INVALID&#39;,
      &#39;API 키를 사용할 수 없습니다.&#39;,
      &#39;입력한 API 키가 정확하지 않거나 삭제·재발급되어 현재 요청에 사용할 수 없습니다.&#39;,
      [
        &#39;API 키 일부가 누락되었을 수 있습니다.&#39;,
        &#39;Google Cloud에서 기존 키가 삭제되거나 재발급되었을 수 있습니다.&#39;,
        &#39;API 키가 아닌 다른 프로젝트 값을 입력했을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;키 유형이 ‘API 키’인지 확인합니다.&#39;,
        &#39;API 키 전체를 다시 복사합니다.&#39;,
        &#39;사이드바에서 API 키를 다시 입력하고 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;accessnotconfigured&#39;) &gt;= 0 ||
    combined.indexOf(&#39;access not configured&#39;) &gt;= 0 ||
    combined.indexOf(&#39;service_disabled&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api_disabled&#39;) &gt;= 0 ||
    combined.indexOf(&#39;has not been used in project&#39;) &gt;= 0 ||
    combined.indexOf(&#39;it is disabled&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_API_NOT_ENABLED&#39;,
      &#39;YouTube Data API v3가 활성화되어 있지 않습니다.&#39;,
      &#39;API 키가 속한 Google Cloud 프로젝트에서 YouTube Data API v3 사용 설정을 켜야 합니다.&#39;,
      [
        &#39;API 키를 만든 프로젝트에서 YouTube Data API v3를 아직 활성화하지 않았습니다.&#39;,
        &#39;API를 활성화한 프로젝트와 API 키를 발급한 프로젝트가 다를 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console에서 API 키를 만든 프로젝트를 선택합니다.&#39;,
        &#39;‘API 및 서비스 → 라이브러리’로 이동합니다.&#39;,
        &#39;‘YouTube Data API v3’를 검색하고 ‘사용’을 누릅니다.&#39;,
        &#39;설정 반영을 위해 1~5분 기다린 뒤 API 키를 다시 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;YouTube Data API 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/library/youtube.googleapis.com&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;iprefererblocked&#39;) &gt;= 0 ||
    combined.indexOf(&#39;referer&#39;) &gt;= 0 &amp;&amp; combined.indexOf(&#39;blocked&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api key restrictions&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_APPLICATION_RESTRICTION&#39;,
      &#39;API 키의 애플리케이션 제한이 Apps Script 요청을 차단했습니다.&#39;,
      &#39;현재 키에 설정된 웹사이트·IP 제한이 Apps Script의 UrlFetch 요청 방식과 맞지 않습니다.&#39;,
      [
        &#39;HTTP 리퍼러 제한 또는 특정 IP 주소 제한이 적용되어 있을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;사용 중인 API 키의 애플리케이션 제한을 확인합니다.&#39;,
        &#39;Apps Script에서 사용할 수 있는 제한 방식으로 조정합니다.&#39;,
        &#39;API 제한은 ‘YouTube Data API v3’로 유지합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 제한 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;accessnotconfigured&#39;) &lt; 0 &amp;&amp;
    (
      combined.indexOf(&#39;forbidden&#39;) &gt;= 0 &amp;&amp;
      combined.indexOf(&#39;api&#39;) &gt;= 0 &amp;&amp;
      combined.indexOf(&#39;key&#39;) &gt;= 0
    )
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_API_RESTRICTION&#39;,
      &#39;API 키의 API 제한 설정을 확인해야 합니다.&#39;,
      &#39;현재 API 키가 YouTube Data API v3 호출을 허용하지 않을 수 있습니다.&#39;,
      [
        &#39;API 키의 ‘API 제한’ 목록에서 YouTube Data API v3가 빠져 있을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;사용 중인 API 키를 선택합니다.&#39;,
        &#39;‘API 제한’에서 YouTube Data API v3를 허용합니다.&#39;,
        &#39;저장 후 1~5분 뒤 다시 실행합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 제한 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;quotaexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;dailylimitexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;daily limit&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_QUOTA_EXCEEDED&#39;,
      &#39;YouTube API의 오늘 할당량을 모두 사용했습니다.&#39;,
      &#39;현재 Google Cloud 프로젝트에서 오늘 사용할 수 있는 YouTube Data API 할당량이 남아 있지 않습니다.&#39;,
      [&#39;같은 프로젝트의 다른 프로그램에서도 YouTube API 할당량을 사용했을 수 있습니다.&#39;],
      [
        &#39;Google Cloud Console에서 YouTube Data API 사용량을 확인합니다.&#39;,
        &#39;일일 할당량이 갱신된 뒤 다시 실행합니다.&#39;,
        &#39;필요하면 다음 수집의 댓글 개수를 줄입니다.&#39;
      ],
      {
        helpLabel: &#39;YouTube API 할당량 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas&#39;,
        actionLabel: &#39;확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;ratelimitexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;userratelimitexceeded&#39;) &gt;= 0 ||
    statusCode === 429
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_RATE_LIMIT&#39;,
      &#39;짧은 시간에 API 요청이 많이 발생했습니다.&#39;,
      &#39;YouTube Data API가 일시적으로 요청 속도를 제한했습니다.&#39;,
      [&#39;여러 수집 작업을 연속으로 실행했을 수 있습니다.&#39;],
      [
        &#39;현재 작업이 자동 재개되는지 확인합니다.&#39;,
        &#39;몇 분 뒤 다시 실행합니다.&#39;,
        &#39;동시에 여러 스프레드시트에서 같은 API 키를 사용하고 있다면 실행 횟수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (combined.indexOf(&#39;commentsdisabled&#39;) &gt;= 0) {
    return buildErrorInfo_(
      &#39;COMMENTS_DISABLED&#39;,
      &#39;이 영상에서는 댓글을 수집할 수 없습니다.&#39;,
      &#39;영상 게시자가 댓글 기능을 사용하지 않도록 설정했습니다.&#39;,
      [&#39;영상의 댓글 기능이 꺼져 있거나 YouTube 정책에 따라 제한되었습니다.&#39;],
      [
        &#39;영상 페이지에서 댓글 영역이 표시되는지 확인합니다.&#39;,
        &#39;댓글이 허용된 다른 영상 주소를 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;videonotfound&#39;) &gt;= 0 ||
    combined.indexOf(&#39;video not found&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;VIDEO_NOT_FOUND&#39;,
      &#39;영상 정보를 찾을 수 없습니다.&#39;,
      &#39;영상 주소가 잘못되었거나 영상이 삭제·비공개 상태일 수 있습니다.&#39;,
      [
        &#39;영상 ID가 정확하지 않을 수 있습니다.&#39;,
        &#39;영상 공개 상태가 변경되었을 수 있습니다.&#39;
      ],
      [
        &#39;브라우저에서 영상 주소가 정상적으로 열리는지 확인합니다.&#39;,
        &#39;영상 URL 전체를 다시 복사해 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (statusCode &gt;= 500) {
    return buildErrorInfo_(
      &#39;YOUTUBE_SERVER_ERROR&#39;,
      &#39;YouTube 서버에서 일시적인 오류가 발생했습니다.&#39;,
      &#39;YouTube Data API 서버가 현재 요청을 정상적으로 처리하지 못했습니다.&#39;,
      [&#39;YouTube 측의 일시적인 서비스 장애일 수 있습니다.&#39;],
      [
        &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
        &#39;진행 중인 수집이라면 자동 재개 여부를 확인합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (statusCode === 403) {
    return buildErrorInfo_(
      &#39;YOUTUBE_ACCESS_FORBIDDEN&#39;,
      &#39;YouTube 데이터에 접근할 수 없습니다.&#39;,
      &#39;API 키 설정, 영상 공개 상태 또는 Google Cloud 프로젝트 권한을 확인해야 합니다.&#39;,
      [
        &#39;API 키 제한 설정이 요청을 차단했을 수 있습니다.&#39;,
        &#39;영상이 공개 API에서 접근할 수 없는 상태일 수 있습니다.&#39;
      ],
      [
        &#39;API 키의 API 제한과 애플리케이션 제한을 확인합니다.&#39;,
        &#39;영상이 공개 상태인지 확인합니다.&#39;,
        &#39;Google Cloud에서 YouTube Data API v3가 활성화되어 있는지 확인합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (statusCode === 400) {
    return buildErrorInfo_(
      &#39;YOUTUBE_BAD_REQUEST&#39;,
      &#39;YouTube API 요청 형식을 확인해야 합니다.&#39;,
      &#39;영상 주소 또는 요청 매개변수 중 하나를 YouTube API가 처리하지 못했습니다.&#39;,
      [&#39;영상 주소가 올바르지 않거나 API 요청 값이 허용 범위를 벗어났을 수 있습니다.&#39;],
      [
        &#39;영상 주소를 다시 확인합니다.&#39;,
        &#39;수집 개수와 정렬 기준을 확인합니다.&#39;,
        &#39;같은 문제가 계속되면 새 수집으로 다시 시작합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;입력값 확인&#39;,
        technical: technical
      }
    );
  }

  return buildErrorInfo_(
    &#39;YOUTUBE_API_ERROR&#39;,
    &#39;YouTube API 요청을 완료하지 못했습니다.&#39;,
    &#39;YouTube Data API가 요청을 거부하거나 처리하지 못했습니다.&#39;,
    [&#39;API 키, 영상 공개 상태, 할당량 또는 네트워크 상태를 확인해야 합니다.&#39;],
    [
      &#39;API 키 설정과 YouTube Data API 활성화 여부를 확인합니다.&#39;,
      &#39;영상 주소가 공개 영상인지 확인합니다.&#39;,
      &#39;잠시 뒤 다시 실행합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: technical }
  );
}


/**
 * 영상 URL 또는 11자리 영상 ID에서 videoId를 추출합니다.
 */
function extractVideoId_(input) {
  const text = String(input || &#39;&#39;).trim();

  if (/^[A-Za-z0-9_-]{11}$/.test(text)) {
    return text;
  }

  const patterns = [
    /[?&amp;]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 &lt; patterns.length; i += 1) {
    const match = text.match(patterns[i]);

    if (match) {
      return match[1];
    }
  }

  throwStructuredError_(buildErrorInfo_(
    &#39;INVALID_VIDEO_URL&#39;,
    &#39;YouTube 영상 주소를 확인해야 합니다.&#39;,
    &#39;입력한 값에서 11자리 YouTube 영상 ID를 찾지 못했습니다.&#39;,
    [
      &#39;영상 주소 일부가 빠졌을 수 있습니다.&#39;,
      &#39;채널·재생목록 주소를 입력했을 수 있습니다.&#39;
    ],
    [
      &#39;YouTube에서 수집할 영상을 엽니다.&#39;,
      &#39;브라우저 주소 표시줄의 영상 URL 전체를 복사합니다.&#39;,
      &#39;사이드바의 영상 주소 입력란에 붙여 넣습니다.&#39;
    ],
    {
      focusTarget: &#39;videoUrl&#39;,
      actionLabel: &#39;영상 주소 다시 입력&#39;
    }
  ));
}


/**
 * 사이드바 입력값을 검증합니다.
 */
function normalizeSidebarInput_(formData) {
  const form = formData || {};
  const videoUrl = String(form.videoUrl || &#39;&#39;).trim();
  const limit = parseInt(form.limit, 10);

  if (!videoUrl) {
    throwStructuredError_(buildErrorInfo_(
      &#39;VIDEO_URL_REQUIRED&#39;,
      &#39;YouTube 영상 주소를 입력해야 합니다.&#39;,
      &#39;수집 대상 영상 주소가 비어 있습니다.&#39;,
      [&#39;영상 주소 입력란에 값이 없습니다.&#39;],
      [&#39;수집할 YouTube 영상의 URL을 입력합니다.&#39;],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 입력&#39;
      }
    ));
  }

  if (!Number.isInteger(limit)) {
    throwStructuredError_(buildErrorInfo_(
      &#39;COMMENT_LIMIT_INVALID&#39;,
      &#39;수집 개수를 정수로 입력해야 합니다.&#39;,
      &#39;수집할 최상위 댓글 개수가 올바른 정수 형식이 아닙니다.&#39;,
      [&#39;빈 값, 소수 또는 문자가 입력되었을 수 있습니다.&#39;],
      [&#39;1 이상의 정수를 입력합니다.&#39;],
      {
        focusTarget: &#39;limit&#39;,
        actionLabel: &#39;수집 개수 입력&#39;
      }
    ));
  }

  if (limit &lt; YT_CFG.MIN_LIMIT || limit &gt; YT_CFG.MAX_LIMIT) {
    throwStructuredError_(buildErrorInfo_(
      &#39;COMMENT_LIMIT_OUT_OF_RANGE&#39;,
      &#39;수집 개수가 허용 범위를 벗어났습니다.&#39;,
      &#39;수집 개수는 &#39; + YT_CFG.MIN_LIMIT + &#39;개 이상 &#39; +
        YT_CFG.MAX_LIMIT + &#39;개 이하로 입력해야 합니다.&#39;,
      [&#39;입력한 개수가 코드에 설정된 운영 범위를 벗어났습니다.&#39;],
      [
        YT_CFG.MIN_LIMIT + &#39;~&#39; + YT_CFG.MAX_LIMIT +
          &#39; 사이의 정수를 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;limit&#39;,
        actionLabel: &#39;수집 개수 수정&#39;
      }
    ));
  }

  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 ... 순서로 만듭니다.
 *
 * 새 스프레드시트에 기본 빈 &#39;시트1&#39; 하나만 존재하는 경우에는 그 시트를
 * 첫 결과 시트로 사용합니다. 그 이후 수집부터는 항상 새 시트를 추가합니다.
 */
function createNextOutputSheet_(ss) {
  const sheets = ss.getSheets();

  if (
    sheets.length === 1 &amp;&amp;
    sheets[0].getName() === YT_CFG.OUTPUT_SHEET_PREFIX + &#39;1&#39; &amp;&amp;
    isSheetCompletelyBlank_(sheets[0])
  ) {
    prepareOutputSheet_(sheets[0]);
    return sheets[0];
  }

  let maxNumber = 0;
  const namePattern = new RegExp(
    &#39;^&#39; + escapeRegExp_(YT_CFG.OUTPUT_SHEET_PREFIX) + &#39;(\\d+)$&#39;
  );

  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 &amp;&amp; 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(&#39;A:A&#39;).setNumberFormat(&#39;@&#39;);
  formatCommentsSheet_(sheet);
}


/**
 * 새 수집을 위해 숨김 내부 시트만 초기화합니다.
 * 이전 결과 시트의 내용은 변경하지 않습니다.
 */
function resetInternalData_(ss) {
  const internal = ss.getSheetByName(YT_CFG.INTERNAL_SHEET);

  if (!internal) {
    throwStructuredError_(buildErrorInfo_(
      &#39;INTERNAL_SHEET_NOT_FOUND&#39;,
      &#39;내부 관리 시트를 준비하지 못했습니다.&#39;,
      &#39;댓글 수집 상태를 저장하는 숨김 시트가 존재하지 않습니다.&#39;,
      [&#39;내부 시트 생성 과정이 완료되지 않았습니다.&#39;],
      [&#39;사이드바를 닫았다가 다시 열고 새 수집을 시작합니다.&#39;],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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 &gt; 0) {
    outputSheet = ss.getSheets().find(function(sheet) {
      return sheet.getSheetId() === expectedId;
    }) || null;
  }

  if (!outputSheet &amp;&amp; state.outputSheetName) {
    outputSheet = ss.getSheetByName(String(state.outputSheetName));
  }

  if (!outputSheet) {
    throwStructuredError_(buildErrorInfo_(
      &#39;OUTPUT_SHEET_NOT_FOUND&#39;,
      &#39;수집 결과 시트를 찾을 수 없습니다.&#39;,
      &#39;현재 수집 작업과 연결된 결과 시트가 삭제되었거나 이름과 식별 정보가 변경되었습니다.&#39;,
      [
        &#39;수집 중 결과 시트를 삭제했을 수 있습니다.&#39;,
        &#39;스프레드시트 구조가 외부 작업으로 변경되었을 수 있습니다.&#39;
      ],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;사이드바에서 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;새 수집 필요&#39; }
    ));
  }

  return outputSheet;
}


function escapeRegExp_(text) {
  return String(text).replace(/[.*+?^${}()|[\]\\]/g, &#39;\\$&amp;&#39;);
}


/**
 * 이전 버전이 만든 수집로그 시트를 제거합니다.
 */
function deleteLegacyLogSheet_(ss) {
  const logSheet = ss.getSheetByName(YT_CFG.LEGACY_LOG_SHEET);

  if (logSheet &amp;&amp; ss.getSheets().length &gt; 1) {
    ss.deleteSheet(logSheet);
  }
}


/**
 * 댓글 시트를 번호 순으로 정렬하고 최종 서식을 적용합니다.
 */
function finalizeCommentsSheet_(sheet) {
  sortCommentsByDisplayNumber_(sheet);
  formatCommentsSheet_(sheet);
}


function sortCommentsByDisplayNumber_(sheet) {
  const lastRow = sheet.getLastRow();

  if (lastRow &lt;= 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] || &#39;&#39;).split(&#39;_&#39;);
    return [
      Number(parts[0] || 0),
      parts.length &gt; 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(&#39;bold&#39;)
    .setWrap(true)
    .setVerticalAlignment(&#39;middle&#39;);

  sheet.getRange(&#39;A:A&#39;).setNumberFormat(&#39;@&#39;);

  if (lastRow &gt; 1) {
    sheet
      .getRange(2, 7, lastRow - 1, 1)
      .setNumberFormat(&#39;yyyy-mm-dd hh:mm:ss&#39;);

    sheet
      .getRange(2, 5, lastRow - 1, 1)
      .setWrap(true)
      .setVerticalAlignment(&#39;top&#39;);

    sheet
      .getRange(2, 1, lastRow - 1, YT_CFG.HEADERS.length)
      .setVerticalAlignment(&#39;top&#39;);

    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 &lt;= 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 &lt;= 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] === &#39;topLevel&#39;) {
      counts.top += 1;
    } else if (row[0] === &#39;reply&#39;) {
      counts.reply += 1;
    }
  });

  return counts;
}


/**
 * 댓글 글자수를 지정된 바이트 규칙으로 계산합니다.
 * - ASCII 영역(영문, 숫자, 공백, 일반 문장부호): 1바이트
 * - 한글을 포함한 비ASCII 유니코드 문자: 코드 포인트당 2바이트
 */
function calculateCommentBytes_(value) {
  const text = String(value || &#39;&#39;);
  let bytes = 0;

  Array.from(text).forEach(function(character) {
    const codePoint = character.codePointAt(0);
    bytes += codePoint &lt;= 0x7F ? 1 : 2;
  });

  return bytes;
}


/**
 * 외부 문자열이 시트 수식으로 실행되는 것을 방지합니다.
 */
function safeCellText_(value) {
  const text = value === undefined || value === null ? &#39;&#39; : String(value);
  return /^[=+\-@]/.test(text) ? &quot;&#39;&quot; + text : text;
}


function apiDateToSheetValue_(value) {
  return value ? new Date(value) : &#39;&#39;;
}


function ensureColumnCount_(sheet, requiredColumns) {
  const current = sheet.getMaxColumns();

  if (current &lt; requiredColumns) {
    sheet.insertColumnsAfter(current, requiredColumns - current);
  }
}


function ensureExactColumnCount_(sheet, requiredColumns) {
  ensureColumnCount_(sheet, requiredColumns);

  const current = sheet.getMaxColumns();

  if (current &gt; 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_(
      &#39;API_KEY_REQUIRED&#39;,
      &#39;API 키 설정이 필요합니다.&#39;,
      &#39;댓글 수집을 시작하기 전에 YouTube Data API 키를 저장해야 합니다.&#39;,
      [&#39;현재 사용자 계정에 저장된 API 키가 없습니다.&#39;],
      [
        &#39;사이드바 상단의 API 키 입력란에 키를 입력합니다.&#39;,
        &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
      ],
      {
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 입력&#39;
      }
    ));
  }

  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 &gt; 8500) {
    throwStructuredError_(buildErrorInfo_(
      &#39;STATE_STORAGE_LIMIT&#39;,
      &#39;수집 진행 상태를 저장할 수 없습니다.&#39;,
      &#39;Apps Script 사용자 속성의 안전 저장 크기를 초과했습니다.&#39;,
      [&#39;진행 상태 데이터가 예상보다 커졌습니다.&#39;],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;댓글 수집 개수를 줄여 새로 실행합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, 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 || &#39;&#39;,
    videoId: state.videoId || &#39;&#39;,
    videoTitle: state.videoTitle || &#39;&#39;,
    targetTopLevelCount: Number(state.targetTopLevelCount || 0),
    topCount: Number(state.topCount || 0),
    replyCount: Number(state.replyCount || 0),
    apiCalls: Number(state.apiCalls || 0),
    startedAt: state.startedAt || &#39;&#39;,
    updatedAt: new Date().toISOString(),
    finishedAt: new Date().toISOString(),
    message: message || &#39;&#39;,
    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_(
      &#39;STATE_VERSION_MISMATCH&#39;,
      &#39;이전 코드에서 시작된 수집 작업을 이어갈 수 없습니다.&#39;,
      &#39;현재 Code.gs의 댓글 저장 구조가 이전 실행 상태와 다릅니다.&#39;,
      [&#39;고정 결과 시트 또는 정렬 기준을 사용하던 이전 코드의 작업 상태가 남아 있습니다.&#39;],
      [
        &#39;사이드바에서 진행 중인 수집을 취소합니다.&#39;,
        &#39;현재 코드로 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;새 수집 필요&#39; }
    ));
  }
}


function normalizeState_(state) {
  state.currentReplyParentId = state.currentReplyParentId || &#39;&#39;;
  state.nextReplyPageToken = state.nextReplyPageToken || &#39;&#39;;
  state.nextThreadPageToken = state.nextThreadPageToken || &#39;&#39;;
  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 || &#39;&#39;);
  state.error = state.error || null;
}


function maskApiKey_(apiKey) {
  const key = String(apiKey || &#39;&#39;);
  return &#39;••••••••••••&#39; + 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 &gt; 0
    ? Math.min(100, Math.round((top / target) * 100))
    : 0;

  let stage = &#39;대기 중&#39;;

  if (status === &#39;RUNNING&#39;) {
    if (state.currentReplyParentId) {
      stage = &#39;답글 수집 중&#39;;
    } else if (!state.threadDone) {
      stage = &#39;최상위 댓글 수집 중&#39;;
    } else {
      stage = &#39;마무리 중&#39;;
    }
  } else if (status === &#39;COMPLETED&#39;) {
    stage = &#39;완료&#39;;
  } else if (status === &#39;ERROR&#39;) {
    stage = &#39;오류&#39;;
  } else if (status === &#39;CANCELLED&#39;) {
    stage = &#39;취소됨&#39;;
  }

  return {
    status: status,
    stage: stage,
    outputSheetId: Number(state.outputSheetId || 0),
    outputSheetName: state.outputSheetName || &#39;&#39;,
    videoId: state.videoId || &#39;&#39;,
    videoTitle: state.videoTitle || &#39;&#39;,
    targetTopLevelCount: target,
    topCount: top,
    replyCount: reply,
    totalRows: top + reply,
    apiCalls: Number(state.apiCalls || 0),
    percent: percent,
    startedAt: state.startedAt || &#39;&#39;,
    updatedAt: state.updatedAt || &#39;&#39;,
    message: state.message || &#39;&#39;,
    error: state.error || null
  };
}


function buildServerProgressMessage_(state) {
  return (
    &#39;최상위 댓글 &#39; +
    state.topCount +
    &#39;/&#39; +
    state.targetTopLevelCount +
    &#39;개, 답글 &#39; +
    state.replyCount +
    &#39;개를 저장했습니다.&#39;
  );
}


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 || &#39;UNKNOWN_ERROR&#39;),
    title: String(title || &#39;작업을 완료하지 못했습니다.&#39;),
    summary: String(summary || &#39;처리 중 문제가 발생했습니다.&#39;),
    causes: Array.isArray(causes) ? causes.map(String) : [],
    steps: Array.isArray(steps) ? steps.map(String) : [],
    helpLabel: String(opts.helpLabel || &#39;&#39;),
    helpUrl: String(opts.helpUrl || &#39;&#39;),
    focusTarget: String(opts.focusTarget || &#39;&#39;),
    actionLabel: String(opts.actionLabel || &#39;확인&#39;),
    technical:
      opts.technical &amp;&amp; typeof opts.technical === &#39;object&#39;
        ? 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 || &#39;&#39;);
  const markerIndex = raw.indexOf(YT_CFG.STRUCTURED_ERROR_PREFIX);

  if (markerIndex &lt; 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 &amp;&amp; error.message
    ? String(error.message)
    : String(error || &#39;&#39;);

  const structured = extractStructuredError_(raw);

  if (structured) {
    return structured;
  }

  const lower = raw.toLowerCase();
  const technical = {
    context: String(context || &#39;작업 실행&#39;),
    originalMessage: raw
  };

  if (
    lower.indexOf(&#39;authorization is required&#39;) &gt;= 0 ||
    lower.indexOf(&#39;you do not have permission&#39;) &gt;= 0 ||
    lower.indexOf(&#39;permissions&#39;) &gt;= 0 &amp;&amp; lower.indexOf(&#39;required&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;,
      &#39;Google 권한 승인이 필요합니다.&#39;,
      &#39;현재 계정이 스프레드시트 또는 외부 API 요청 권한을 아직 승인하지 않았습니다.&#39;,
      [&#39;Apps Script를 처음 실행했거나 새로운 권한이 추가되었습니다.&#39;],
      [
        &#39;스프레드시트 메뉴에서 사이드바를 다시 엽니다.&#39;,
        &#39;권한 검토 화면에서 현재 Google 계정을 선택합니다.&#39;,
        &#39;요청되는 권한을 허용한 뒤 다시 실행합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (
    lower.indexOf(&#39;maximum execution time&#39;) &gt;= 0 ||
    lower.indexOf(&#39;exceeded maximum execution time&#39;) &gt;= 0 ||
    lower.indexOf(&#39;timed out&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;,
      &#39;한 번의 실행 가능 시간을 초과했습니다.&#39;,
      &#39;댓글 또는 답글 처리 시간이 길어 현재 실행 구간이 종료되었습니다.&#39;,
      [&#39;대량 댓글 수집으로 한 번의 Apps Script 실행 시간이 길어졌습니다.&#39;],
      [
        &#39;사이드바를 다시 열어 진행 상태를 확인합니다.&#39;,
        &#39;멈춘 경우 ‘중단 지점부터 실행’을 한 번 실행합니다.&#39;,
        &#39;같은 문제가 반복되면 수집 개수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (
    lower.indexOf(&#39;service invoked too many times&#39;) &gt;= 0 ||
    lower.indexOf(&#39;quota&#39;) &gt;= 0 &amp;&amp; lower.indexOf(&#39;apps script&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_SERVICE_QUOTA&#39;,
      &#39;Apps Script 서비스 사용 한도를 초과했습니다.&#39;,
      &#39;현재 Google 계정 또는 스크립트의 일일 서비스 사용량이 허용 범위를 넘었습니다.&#39;,
      [&#39;짧은 시간에 많은 외부 요청이나 스프레드시트 쓰기 작업이 실행되었습니다.&#39;],
      [
        &#39;잠시 뒤 다시 실행합니다.&#39;,
        &#39;같은 날 반복되면 다음 날 다시 실행합니다.&#39;,
        &#39;한 번에 수집하는 댓글 개수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  return buildErrorInfo_(
    &#39;UNEXPECTED_ERROR&#39;,
    &#39;예상하지 못한 오류가 발생했습니다.&#39;,
    &#39;오류 유형을 자동으로 판별하지 못했습니다.&#39;,
    [
      &#39;네트워크, 권한, Google 서비스 상태 또는 실행 환경에서 문제가 발생했을 수 있습니다.&#39;
    ],
    [
      &#39;사이드바를 닫았다가 다시 열고 같은 작업을 한 번 더 실행합니다.&#39;,
      &#39;Apps Script 편집기의 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;,
      &#39;API 키와 영상 주소를 다시 확인합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: technical }
  );
}</pre></section><section><h3>Sidebar.html</h3><pre>&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;meta charset=&quot;utf-8&quot;&gt;
    &lt;style&gt;
      :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, &#39;Noto Sans KR&#39;, 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=&#39;text&#39;],
      input[type=&#39;password&#39;],
      input[type=&#39;number&#39;] {
        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);
        }
      }
    &lt;/style&gt;
  &lt;/head&gt;

  &lt;body&gt;
    &lt;h1&gt;YouTube 댓글 수집기&lt;/h1&gt;
    &lt;p class=&quot;subtitle&quot;&gt;YouTube Data API v3 → Google Sheets&lt;/p&gt;

    &lt;div id=&quot;message&quot; class=&quot;message&quot;&gt;&lt;/div&gt;

    &lt;section class=&quot;card&quot;&gt;
      &lt;h2&gt;1. API 키&lt;/h2&gt;

      &lt;div id=&quot;apiMissingView&quot;&gt;
        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;apiKey&quot;&gt;YouTube Data API 키&lt;/label&gt;
          &lt;input
            id=&quot;apiKey&quot;
            type=&quot;password&quot;
            autocomplete=&quot;off&quot;
            placeholder=&quot;AIza...&quot;
          &gt;
          &lt;div class=&quot;help&quot;&gt;
            최초 1회 저장합니다. 키는 사용자별 Apps Script 속성에 보관됩니다.
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;button id=&quot;saveApiButton&quot; class=&quot;primary&quot; type=&quot;button&quot;&gt;
          API 키 확인 후 저장
        &lt;/button&gt;
      &lt;/div&gt;

      &lt;div id=&quot;apiSavedView&quot; class=&quot;hidden&quot;&gt;
        &lt;div class=&quot;row between&quot;&gt;
          &lt;div class=&quot;status-line&quot;&gt;
            &lt;span class=&quot;badge success&quot;&gt;설정 완료&lt;/span&gt;
            &lt;span id=&quot;apiKeyMask&quot; class=&quot;masked-key&quot;&gt;&lt;/span&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;div class=&quot;row&quot; style=&quot;margin-top: 10px;&quot;&gt;
          &lt;button id=&quot;changeApiButton&quot; class=&quot;secondary small-button&quot; type=&quot;button&quot;&gt;
            키 변경
          &lt;/button&gt;
          &lt;button id=&quot;deleteApiButton&quot; class=&quot;danger small-button&quot; type=&quot;button&quot;&gt;
            키 삭제
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/section&gt;

    &lt;section class=&quot;card soft&quot;&gt;
      &lt;h2&gt;2. 수집 설정&lt;/h2&gt;

      &lt;div id=&quot;lockedMessage&quot; class=&quot;locked-message&quot;&gt;
        API 키를 저장하면 수집 설정이 활성화됩니다.
      &lt;/div&gt;

      &lt;fieldset id=&quot;collectorFieldset&quot; disabled&gt;
        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;videoUrl&quot;&gt;YouTube 영상 주소&lt;/label&gt;
          &lt;input
            id=&quot;videoUrl&quot;
            type=&quot;text&quot;
            placeholder=&quot;https://www.youtube.com/watch?v=...&quot;
          &gt;
        &lt;/div&gt;

        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;limit&quot;&gt;수집할 최상위 댓글 수&lt;/label&gt;
          &lt;input
            id=&quot;limit&quot;
            type=&quot;number&quot;
            min=&quot;1&quot;
            max=&quot;50000&quot;
            step=&quot;1&quot;
            value=&quot;100&quot;
          &gt;
          &lt;div class=&quot;help&quot;&gt;
            1~50,000개. 답글 포함 시 실제 저장 행 수가 이 값보다 많아질 수 있습니다.
          &lt;/div&gt;
        &lt;/div&gt;


        &lt;div class=&quot;field check-row&quot;&gt;
          &lt;input id=&quot;includeReplies&quot; type=&quot;checkbox&quot; checked&gt;
          &lt;label for=&quot;includeReplies&quot;&gt;
            대댓글도 모두 수집
          &lt;/label&gt;
        &lt;/div&gt;

        &lt;div class=&quot;help&quot; style=&quot;margin-bottom: 10px;&quot;&gt;
          새 수집을 시작할 때마다 ‘시트1’, ‘시트2’, ‘시트3’ 형식의 새 시트가 생성됩니다. 기존 수집 결과는 유지됩니다.
        &lt;/div&gt;

        &lt;button id=&quot;startButton&quot; class=&quot;primary&quot; type=&quot;button&quot;&gt;
          수집 시작
        &lt;/button&gt;
      &lt;/fieldset&gt;
    &lt;/section&gt;

    &lt;section id=&quot;progressCard&quot; class=&quot;card hidden&quot;&gt;
      &lt;div class=&quot;row between&quot;&gt;
        &lt;h2 style=&quot;margin: 0;&quot;&gt;진행 상태&lt;/h2&gt;
        &lt;span id=&quot;progressBadge&quot; class=&quot;badge warning&quot;&gt;대기 중&lt;/span&gt;
      &lt;/div&gt;

      &lt;div id=&quot;progressTitle&quot; class=&quot;progress-title&quot; style=&quot;margin-top: 10px;&quot;&gt;&lt;/div&gt;

      &lt;div class=&quot;progress-bar&quot;&gt;
        &lt;div id=&quot;progressFill&quot; class=&quot;progress-fill&quot;&gt;&lt;/div&gt;
      &lt;/div&gt;

      &lt;div class=&quot;metric-grid&quot;&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;댓글&lt;/div&gt;
          &lt;div id=&quot;topMetric&quot; class=&quot;metric-value&quot;&gt;0 / 0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;대댓글&lt;/div&gt;
          &lt;div id=&quot;replyMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;전체 저장 행&lt;/div&gt;
          &lt;div id=&quot;totalMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;API 호출 횟수&lt;/div&gt;
          &lt;div id=&quot;apiCallMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;p id=&quot;progressMessage&quot; class=&quot;help&quot;&gt;&lt;/p&gt;

      &lt;button id=&quot;cancelButton&quot; class=&quot;danger hidden&quot; type=&quot;button&quot; style=&quot;width: 100%; margin-top: 12px;&quot;&gt;
        진행 중인 수집 취소
      &lt;/button&gt;
    &lt;/section&gt;


    &lt;div
      id=&quot;errorModalBackdrop&quot;
      class=&quot;modal-backdrop hidden&quot;
      role=&quot;dialog&quot;
      aria-modal=&quot;true&quot;
      aria-labelledby=&quot;errorModalTitle&quot;
    &gt;
      &lt;div class=&quot;modal-panel&quot;&gt;
        &lt;div class=&quot;modal-header&quot;&gt;
          &lt;div class=&quot;modal-icon&quot; aria-hidden=&quot;true&quot;&gt;!&lt;/div&gt;

          &lt;div class=&quot;modal-heading&quot;&gt;
            &lt;h2 id=&quot;errorModalTitle&quot; class=&quot;modal-title&quot;&gt;
              문제를 확인해야 합니다.
            &lt;/h2&gt;
            &lt;div id=&quot;errorModalCode&quot; class=&quot;modal-code&quot;&gt;&lt;/div&gt;
          &lt;/div&gt;

          &lt;button
            id=&quot;errorModalCloseButton&quot;
            class=&quot;modal-close&quot;
            type=&quot;button&quot;
            aria-label=&quot;오류 안내 닫기&quot;
          &gt;
            ×
          &lt;/button&gt;
        &lt;/div&gt;

        &lt;div class=&quot;modal-body&quot;&gt;
          &lt;div id=&quot;errorModalSummary&quot; class=&quot;modal-summary&quot;&gt;&lt;/div&gt;

          &lt;section id=&quot;errorCauseSection&quot; class=&quot;modal-section&quot;&gt;
            &lt;h3&gt;발생 가능한 원인&lt;/h3&gt;
            &lt;ul id=&quot;errorCauseList&quot;&gt;&lt;/ul&gt;
          &lt;/section&gt;

          &lt;section id=&quot;errorStepSection&quot; class=&quot;modal-section&quot;&gt;
            &lt;h3&gt;해결 방법&lt;/h3&gt;
            &lt;ol id=&quot;errorStepList&quot;&gt;&lt;/ol&gt;
          &lt;/section&gt;

          &lt;details id=&quot;errorTechnicalDetails&quot; class=&quot;technical-details&quot;&gt;
            &lt;summary&gt;기술 정보 보기&lt;/summary&gt;
            &lt;div id=&quot;errorTechnicalGrid&quot; class=&quot;technical-grid&quot;&gt;&lt;/div&gt;
          &lt;/details&gt;
        &lt;/div&gt;

        &lt;div class=&quot;modal-actions&quot;&gt;
          &lt;a
            id=&quot;errorHelpLink&quot;
            class=&quot;modal-help-link hidden&quot;
            href=&quot;#&quot;
            target=&quot;_blank&quot;
            rel=&quot;noopener noreferrer&quot;
          &gt;&lt;/a&gt;

          &lt;button
            id=&quot;errorModalPrimaryButton&quot;
            class=&quot;primary&quot;
            type=&quot;button&quot;
          &gt;
            확인
          &lt;/button&gt;

          &lt;button
            id=&quot;errorModalDismissButton&quot;
            class=&quot;secondary&quot;
            type=&quot;button&quot;
          &gt;
            닫기
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;script&gt;
      const ERROR_PREFIX = &#39;YT_STRUCTURED_ERROR::&#39;;

      let currentState = null;
      let pollTimer = null;
      let pollInFlight = false;
      let lastShownErrorKey = &#39;&#39;;
      let pendingFocusTarget = &#39;&#39;;

      document.addEventListener(&#39;DOMContentLoaded&#39;, function() {
        bindEvents();
        refreshState(false);
      });

      function bindEvents() {
        document.getElementById(&#39;saveApiButton&#39;).addEventListener(&#39;click&#39;, saveApiKey);
        document.getElementById(&#39;changeApiButton&#39;).addEventListener(&#39;click&#39;, showApiKeyEditor);
        document.getElementById(&#39;deleteApiButton&#39;).addEventListener(&#39;click&#39;, deleteApiKey);
        document.getElementById(&#39;startButton&#39;).addEventListener(&#39;click&#39;, startCollection);
        document.getElementById(&#39;cancelButton&#39;).addEventListener(&#39;click&#39;, cancelCollection);

        document
          .getElementById(&#39;errorModalCloseButton&#39;)
          .addEventListener(&#39;click&#39;, closeErrorModal);

        document
          .getElementById(&#39;errorModalDismissButton&#39;)
          .addEventListener(&#39;click&#39;, closeErrorModal);

        document
          .getElementById(&#39;errorModalPrimaryButton&#39;)
          .addEventListener(&#39;click&#39;, handleErrorModalPrimaryAction);

        document
          .getElementById(&#39;errorModalBackdrop&#39;)
          .addEventListener(&#39;click&#39;, function(event) {
            if (event.target === event.currentTarget) {
              closeErrorModal();
            }
          });

        document.addEventListener(&#39;keydown&#39;, function(event) {
          if (event.key === &#39;Escape&#39;) {
            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, &#39;상태 확인&#39;);
            }
          })
          .getSidebarState();
      }

      function renderState(state) {
        currentState = state || {};

        const configured = Boolean(currentState.apiKeyConfigured);
        const running = Boolean(currentState.running);

        document.getElementById(&#39;apiMissingView&#39;).classList.toggle(&#39;hidden&#39;, configured);
        document.getElementById(&#39;apiSavedView&#39;).classList.toggle(&#39;hidden&#39;, !configured);
        document.getElementById(&#39;apiKeyMask&#39;).textContent = currentState.apiKeyMask || &#39;&#39;;

        document.getElementById(&#39;collectorFieldset&#39;).disabled = !configured || running;
        document.getElementById(&#39;lockedMessage&#39;).classList.toggle(&#39;hidden&#39;, configured);

        document.getElementById(&#39;changeApiButton&#39;).disabled = running;
        document.getElementById(&#39;deleteApiButton&#39;).disabled = running;

        renderProgress(currentState.progress, running);

        if (running) {
          startPolling();
        } else {
          stopPolling();
        }
      }

      function renderProgress(progress, running) {
        const card = document.getElementById(&#39;progressCard&#39;);

        if (!progress) {
          card.classList.add(&#39;hidden&#39;);
          return;
        }

        card.classList.remove(&#39;hidden&#39;);

        const status = progress.status || &#39;&#39;;
        const badge = document.getElementById(&#39;progressBadge&#39;);
        badge.textContent = progress.stage || status;
        badge.className = &#39;badge &#39; + getBadgeClass(status);

        const progressTitle =
          progress.videoTitle || progress.videoId || &#39;YouTube 영상&#39;;
        const outputSheetName = progress.outputSheetName || &#39;&#39;;

        document.getElementById(&#39;progressTitle&#39;).textContent =
          outputSheetName
            ? progressTitle + &#39; · &#39; + outputSheetName
            : progressTitle;

        document.getElementById(&#39;progressFill&#39;).style.width =
          String(progress.percent || 0) + &#39;%&#39;;

        document.getElementById(&#39;topMetric&#39;).textContent =
          formatNumber(progress.topCount) + &#39; / &#39; + formatNumber(progress.targetTopLevelCount);

        document.getElementById(&#39;replyMetric&#39;).textContent = formatNumber(progress.replyCount);
        document.getElementById(&#39;totalMetric&#39;).textContent = formatNumber(progress.totalRows);
        document.getElementById(&#39;apiCallMetric&#39;).textContent = formatNumber(progress.apiCalls);
        document.getElementById(&#39;progressMessage&#39;).textContent = progress.message || &#39;&#39;;
        document.getElementById(&#39;cancelButton&#39;).classList.toggle(&#39;hidden&#39;, !running);

        if (status === &#39;ERROR&#39;) {
          const errorInfo = progress.error || buildFallbackErrorInfo(
            progress.message || &#39;&#39;,
            &#39;댓글 수집&#39;
          );

          const errorKey = [
            errorInfo.code || &#39;UNKNOWN_ERROR&#39;,
            progress.updatedAt || &#39;&#39;,
            progress.videoId || &#39;&#39;
          ].join(&#39;|&#39;);

          if (errorKey !== lastShownErrorKey) {
            lastShownErrorKey = errorKey;
            showErrorModal(errorInfo);
          }
        }
      }

      function saveApiKey() {
        const input = document.getElementById(&#39;apiKey&#39;);
        const button = document.getElementById(&#39;saveApiButton&#39;);
        const apiKey = input.value.trim();

        if (!apiKey) {
          showErrorModal({
            code: &#39;API_KEY_REQUIRED&#39;,
            title: &#39;API 키를 입력해야 합니다.&#39;,
            summary: &#39;API 키 입력란이 비어 있어 검증을 시작할 수 없습니다.&#39;,
            causes: [
              &#39;입력란에 값이 입력되지 않았습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’에서 API 키를 확인합니다.&#39;,
              &#39;키 값을 복사하여 입력란에 붙여 넣습니다.&#39;,
              &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
            ],
            helpLabel: &#39;API 키 관리 화면 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;API 키 입력&#39;
          });
          return;
        }

        setButtonBusy(button, true, &#39;확인 중&#39;);
        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            input.value = &#39;&#39;;
            setButtonBusy(button, false, &#39;API 키 확인 후 저장&#39;);
            renderState(state);
            showMessage(&#39;API 키를 검증하여 저장했습니다.&#39;, &#39;success&#39;);
          })
          .withFailureHandler(function(error) {
            setButtonBusy(button, false, &#39;API 키 확인 후 저장&#39;);
            handleServerError(error, &#39;API 키 검증&#39;);
          })
          .saveApiKey(apiKey);
      }

      function showApiKeyEditor() {
        document.getElementById(&#39;apiSavedView&#39;).classList.add(&#39;hidden&#39;);
        document.getElementById(&#39;apiMissingView&#39;).classList.remove(&#39;hidden&#39;);
        document.getElementById(&#39;apiKey&#39;).focus();
        showMessage(&#39;새 API 키를 입력하면 기존 키가 교체됩니다.&#39;, &#39;info&#39;);
      }

      function deleteApiKey() {
        if (!window.confirm(&#39;저장된 API 키를 삭제하시겠습니까?&#39;)) {
          return;
        }

        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            renderState(state);
            showMessage(&#39;저장된 API 키를 삭제했습니다.&#39;, &#39;success&#39;);
          })
          .withFailureHandler(function(error) {
            handleServerError(error, &#39;API 키 삭제&#39;);
          })
          .deleteApiKey();
      }

      function startCollection() {
        const videoUrl = document.getElementById(&#39;videoUrl&#39;).value.trim();
        const limitText = document.getElementById(&#39;limit&#39;).value;
        const limit = Number(limitText);
        const startButton = document.getElementById(&#39;startButton&#39;);

        if (!videoUrl) {
          showErrorModal({
            code: &#39;VIDEO_URL_REQUIRED&#39;,
            title: &#39;영상 주소를 입력해야 합니다.&#39;,
            summary: &#39;수집 대상 YouTube 영상 주소가 비어 있습니다.&#39;,
            causes: [
              &#39;영상 주소 입력란에 값이 없습니다.&#39;
            ],
            steps: [
              &#39;YouTube에서 수집할 영상을 엽니다.&#39;,
              &#39;주소창의 전체 영상 주소를 복사하여 입력합니다.&#39;
            ],
            focusTarget: &#39;videoUrl&#39;,
            actionLabel: &#39;영상 주소 입력&#39;
          });
          return;
        }

        if (!Number.isInteger(limit) || limit &lt; 1 || limit &gt; 50000) {
          showErrorModal({
            code: &#39;COMMENT_LIMIT_INVALID&#39;,
            title: &#39;수집 개수를 확인해야 합니다.&#39;,
            summary: &#39;수집 개수는 1~50,000 사이의 정수로 입력해야 합니다.&#39;,
            causes: [
              &#39;빈 값, 소수, 문자 또는 허용 범위를 벗어난 수가 입력되었습니다.&#39;
            ],
            steps: [
              &#39;수집 개수 입력란에 1~50,000 사이의 정수를 입력합니다.&#39;,
              &#39;예: 100, 500, 1000&#39;
            ],
            focusTarget: &#39;limit&#39;,
            actionLabel: &#39;수집 개수 수정&#39;
          });
          return;
        }

        const payload = {
          videoUrl: videoUrl,
          limit: limit,
          includeReplies: document.getElementById(&#39;includeReplies&#39;).checked
        };

        setButtonBusy(startButton, true, &#39;준비 중&#39;);
        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            setButtonBusy(startButton, false, &#39;수집 시작&#39;);
            renderState(state);
            const sheetName =
              state &amp;&amp;
              state.progress &amp;&amp;
              state.progress.outputSheetName
                ? state.progress.outputSheetName
                : &#39;&#39;;

            showMessage(
              sheetName
                ? sheetName + &#39;에서 수집 작업을 시작했습니다.&#39;
                : &#39;수집 작업을 시작했습니다.&#39;,
              &#39;info&#39;
            );
            startPolling();

            // 서버에서 첫 수집 구간을 즉시 실행합니다.
            google.script.run
              .withSuccessHandler(function(updatedState) {
                renderState(updatedState);
              })
              .withFailureHandler(function(error) {
                refreshState(true);
                handleServerError(error, &#39;댓글 수집&#39;);
              })
              .runCollectionNow();
          })
          .withFailureHandler(function(error) {
            setButtonBusy(startButton, false, &#39;수집 시작&#39;);
            handleServerError(error, &#39;수집 준비&#39;);
          })
          .startCollectionFromSidebar(payload);
      }

      function cancelCollection() {
        if (!window.confirm(&#39;현재 수집 작업을 취소하시겠습니까? 이미 저장된 행은 유지됩니다.&#39;)) {
          return;
        }

        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            renderState(state);

            if (state &amp;&amp; state.running) {
              showMessage(&#39;취소 요청을 보냈습니다. 현재 API 페이지 처리가 끝나면 안전하게 중단됩니다.&#39;, &#39;info&#39;);
              startPolling();
            } else {
              showMessage(&#39;수집 작업을 취소했습니다.&#39;, &#39;success&#39;);
            }
          })
          .withFailureHandler(function(error) {
            handleServerError(error, &#39;수집 취소&#39;);
          })
          .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 = &#39;&lt;span class=&quot;row&quot; style=&quot;justify-content:center;&quot;&gt;&lt;span class=&quot;spinner&quot;&gt;&lt;/span&gt;&lt;span&gt;&#39; +
            escapeHtml(label) +
            &#39;&lt;/span&gt;&lt;/span&gt;&#39;;
        } else {
          button.textContent = label;
        }
      }

      function showMessage(text, type) {
        const box = document.getElementById(&#39;message&#39;);
        box.textContent = text || &#39;&#39;;
        box.className = &#39;message show &#39; + (type || &#39;info&#39;);
      }

      function clearMessage() {
        const box = document.getElementById(&#39;message&#39;);
        box.textContent = &#39;&#39;;
        box.className = &#39;message&#39;;
      }

      function getBadgeClass(status) {
        if (status === &#39;COMPLETED&#39;) {
          return &#39;success&#39;;
        }

        if (status === &#39;ERROR&#39; || status === &#39;CANCELLED&#39;) {
          return &#39;danger&#39;;
        }

        return &#39;warning&#39;;
      }

      function handleServerError(error, context) {
        const errorInfo = parseServerError(error, context);
        showErrorModal(errorInfo);
      }

      function parseServerError(error, context) {
        if (error &amp;&amp; error.title &amp;&amp; error.summary) {
          return normalizeClientErrorInfo(error);
        }

        const raw = error &amp;&amp; error.message
          ? String(error.message)
          : String(error || &#39;&#39;);

        const structured = extractStructuredError(raw);

        if (structured) {
          return normalizeClientErrorInfo(structured);
        }

        return buildFallbackErrorInfo(raw, context);
      }

      function extractStructuredError(rawText) {
        const raw = String(rawText || &#39;&#39;);
        const markerIndex = raw.indexOf(ERROR_PREFIX);

        if (markerIndex &lt; 0) {
          return null;
        }

        const objectStart = raw.indexOf(
          &#39;{&#39;,
          markerIndex + ERROR_PREFIX.length
        );

        if (objectStart &lt; 0) {
          return null;
        }

        let depth = 0;
        let inString = false;
        let escaped = false;

        for (let index = objectStart; index &lt; raw.length; index += 1) {
          const character = raw.charAt(index);

          if (inString) {
            if (escaped) {
              escaped = false;
            } else if (character === &#39;\\&#39;) {
              escaped = true;
            } else if (character === &#39;&quot;&#39;) {
              inString = false;
            }

            continue;
          }

          if (character === &#39;&quot;&#39;) {
            inString = true;
            continue;
          }

          if (character === &#39;{&#39;) {
            depth += 1;
          } else if (character === &#39;}&#39;) {
            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 || &#39;UNKNOWN_ERROR&#39;),
          title: String(info.title || &#39;작업을 완료하지 못했습니다.&#39;),
          summary: String(
            info.summary ||
            &#39;처리 중 예상하지 못한 문제가 발생했습니다.&#39;
          ),
          causes: Array.isArray(info.causes)
            ? info.causes.map(String)
            : [],
          steps: Array.isArray(info.steps)
            ? info.steps.map(String)
            : [],
          helpLabel: String(info.helpLabel || &#39;&#39;),
          helpUrl: String(info.helpUrl || &#39;&#39;),
          focusTarget: String(info.focusTarget || &#39;&#39;),
          actionLabel: String(info.actionLabel || &#39;확인&#39;),
          technical:
            info.technical &amp;&amp; typeof info.technical === &#39;object&#39;
              ? info.technical
              : {}
        };
      }

      function buildFallbackErrorInfo(rawText, context) {
        const raw = String(rawText || &#39;&#39;);
        const lower = raw.toLowerCase();
        const technical = {
          errorCode: &#39;UNCLASSIFIED_CLIENT_ERROR&#39;,
          context: String(context || &#39;작업 실행&#39;)
        };

        if (
          lower.indexOf(&#39;api key not valid&#39;) &gt;= 0 ||
          lower.indexOf(&#39;keyinvalid&#39;) &gt;= 0 ||
          lower.indexOf(&#39;api_key_invalid&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;API_KEY_INVALID&#39;,
            title: &#39;API 키를 사용할 수 없습니다.&#39;,
            summary: &#39;입력한 API 키가 정확하지 않거나 현재 사용할 수 없는 상태입니다.&#39;,
            causes: [
              &#39;키 일부가 누락되었거나 다른 값을 붙여 넣었을 수 있습니다.&#39;,
              &#39;Google Cloud에서 기존 키를 삭제하거나 재발급했을 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
              &#39;실제 API 키 값을 전체 복사합니다.&#39;,
              &#39;사이드바에서 키를 다시 입력하고 저장합니다.&#39;
            ],
            helpLabel: &#39;API 키 관리 화면 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;API 키 다시 입력&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;API_KEY_INVALID&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;access not configured&#39;) &gt;= 0 ||
          lower.indexOf(&#39;accessnotconfigured&#39;) &gt;= 0 ||
          lower.indexOf(&#39;has not been used in project&#39;) &gt;= 0 ||
          lower.indexOf(&#39;api_disabled&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;YOUTUBE_API_NOT_ENABLED&#39;,
            title: &#39;YouTube Data API v3가 활성화되어 있지 않습니다.&#39;,
            summary: &#39;API 키가 속한 Google Cloud 프로젝트에서 YouTube Data API v3 사용 설정을 켜야 합니다.&#39;,
            causes: [
              &#39;API 키를 만든 프로젝트에서 YouTube Data API v3를 아직 활성화하지 않았습니다.&#39;,
              &#39;API를 활성화한 프로젝트와 API 키를 발급한 프로젝트가 다를 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console에서 API 키를 만든 프로젝트를 선택합니다.&#39;,
              &#39;‘API 및 서비스 → 라이브러리’로 이동합니다.&#39;,
              &#39;‘YouTube Data API v3’를 검색하고 ‘사용’을 누릅니다.&#39;,
              &#39;설정 후 1~5분 뒤 API 키를 다시 저장합니다.&#39;
            ],
            helpLabel: &#39;YouTube Data API 설정 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/library/youtube.googleapis.com&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;설정 확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;YOUTUBE_API_NOT_ENABLED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;commentsdisabled&#39;) &gt;= 0 ||
          lower.indexOf(&#39;comments disabled&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;COMMENTS_DISABLED&#39;,
            title: &#39;이 영상에서는 댓글을 수집할 수 없습니다.&#39;,
            summary: &#39;영상 게시자가 댓글 기능을 사용하지 않도록 설정했습니다.&#39;,
            causes: [
              &#39;영상의 댓글 기능이 꺼져 있거나 YouTube 정책에 따라 제한되었습니다.&#39;
            ],
            steps: [
              &#39;영상 페이지에서 댓글 영역이 표시되는지 확인합니다.&#39;,
              &#39;댓글이 허용된 다른 영상 주소를 입력합니다.&#39;
            ],
            focusTarget: &#39;videoUrl&#39;,
            actionLabel: &#39;영상 주소 다시 입력&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;COMMENTS_DISABLED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;quotaexceeded&#39;) &gt;= 0 ||
          lower.indexOf(&#39;quota_exceeded&#39;) &gt;= 0 ||
          lower.indexOf(&#39;daily limit&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;YOUTUBE_QUOTA_EXCEEDED&#39;,
            title: &#39;YouTube API의 오늘 할당량을 모두 사용했습니다.&#39;,
            summary: &#39;현재 프로젝트에서 오늘 사용할 수 있는 YouTube Data API 할당량이 남아 있지 않습니다.&#39;,
            causes: [
              &#39;같은 프로젝트의 다른 작업에서도 YouTube API를 사용했을 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console에서 YouTube Data API의 오늘 사용량을 확인합니다.&#39;,
              &#39;일일 할당량이 갱신된 뒤 다시 실행합니다.&#39;,
              &#39;다음 수집에서는 요청 댓글 수를 줄입니다.&#39;
            ],
            helpLabel: &#39;YouTube API 할당량 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas&#39;,
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;YOUTUBE_QUOTA_EXCEEDED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;authorization is required&#39;) &gt;= 0 ||
          lower.indexOf(&#39;you do not have permission&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;,
            title: &#39;Google 권한 승인이 필요합니다.&#39;,
            summary: &#39;현재 계정이 스프레드시트 또는 외부 API 요청 권한을 아직 승인하지 않았습니다.&#39;,
            causes: [
              &#39;Apps Script를 처음 실행했거나 새로운 권한이 추가되었습니다.&#39;
            ],
            steps: [
              &#39;스프레드시트 메뉴에서 사이드바를 다시 엽니다.&#39;,
              &#39;권한 검토 화면에서 현재 계정을 선택합니다.&#39;,
              &#39;요청되는 권한을 허용한 뒤 다시 실행합니다.&#39;
            ],
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;timed out&#39;) &gt;= 0 ||
          lower.indexOf(&#39;maximum execution time&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;,
            title: &#39;한 번의 실행 가능 시간을 초과했습니다.&#39;,
            summary: &#39;댓글이 많아 현재 실행 구간이 종료되었습니다.&#39;,
            causes: [
              &#39;대량 댓글 또는 답글 처리 시간이 길어졌습니다.&#39;
            ],
            steps: [
              &#39;진행 상태가 자동으로 이어지는지 확인합니다.&#39;,
              &#39;멈춘 경우 ‘중단 지점부터 실행’을 한 번 실행합니다.&#39;,
              &#39;반복되면 수집 개수를 줄입니다.&#39;
            ],
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;
            })
          });
        }

        return normalizeClientErrorInfo({
          code: &#39;UNEXPECTED_ERROR&#39;,
          title: &#39;예상하지 못한 오류가 발생했습니다.&#39;,
          summary: &#39;오류 유형을 자동으로 판별하지 못했습니다. 입력값과 Apps Script 실행 기록을 확인해야 합니다.&#39;,
          causes: [
            &#39;네트워크 연결, Google 서비스 상태, 권한 또는 실행 환경에서 문제가 발생했을 수 있습니다.&#39;
          ],
          steps: [
            &#39;사이드바를 닫았다가 다시 열고 같은 작업을 한 번 더 실행합니다.&#39;,
            &#39;Apps Script 편집기의 왼쪽 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;,
            &#39;API 키 저장 상태와 영상 주소를 다시 확인합니다.&#39;
          ],
          actionLabel: &#39;확인&#39;,
          technical: technical
        });
      }

      function showErrorModal(errorInfo) {
        const info = normalizeClientErrorInfo(errorInfo);
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);
        const title = document.getElementById(&#39;errorModalTitle&#39;);
        const code = document.getElementById(&#39;errorModalCode&#39;);
        const summary = document.getElementById(&#39;errorModalSummary&#39;);
        const causeSection = document.getElementById(&#39;errorCauseSection&#39;);
        const stepSection = document.getElementById(&#39;errorStepSection&#39;);
        const causeList = document.getElementById(&#39;errorCauseList&#39;);
        const stepList = document.getElementById(&#39;errorStepList&#39;);
        const helpLink = document.getElementById(&#39;errorHelpLink&#39;);
        const primaryButton = document.getElementById(&#39;errorModalPrimaryButton&#39;);

        clearMessage();

        title.textContent = info.title;
        code.textContent = &#39;오류 코드: &#39; + info.code;
        summary.textContent = info.summary;

        renderTextList(causeList, info.causes);
        renderTextList(stepList, info.steps);

        causeSection.classList.toggle(&#39;hidden&#39;, info.causes.length === 0);
        stepSection.classList.toggle(&#39;hidden&#39;, info.steps.length === 0);

        if (
          info.helpUrl &amp;&amp;
          /^https:\/\/console\.cloud\.google\.com\//.test(info.helpUrl)
        ) {
          helpLink.href = info.helpUrl;
          helpLink.textContent = info.helpLabel || &#39;Google Cloud 설정 열기&#39;;
          helpLink.classList.remove(&#39;hidden&#39;);
        } else {
          helpLink.removeAttribute(&#39;href&#39;);
          helpLink.textContent = &#39;&#39;;
          helpLink.classList.add(&#39;hidden&#39;);
        }

        primaryButton.textContent = info.actionLabel || &#39;확인&#39;;
        pendingFocusTarget = info.focusTarget || &#39;&#39;;

        renderTechnicalInfo(info.technical);

        backdrop.classList.remove(&#39;hidden&#39;);
        document.body.style.overflow = &#39;hidden&#39;;

        window.setTimeout(function() {
          primaryButton.focus();
        }, 0);
      }

      function renderTextList(listElement, items) {
        listElement.innerHTML = &#39;&#39;;

        (items || []).forEach(function(item) {
          const listItem = document.createElement(&#39;li&#39;);
          listItem.textContent = String(item);
          listElement.appendChild(listItem);
        });
      }

      function renderTechnicalInfo(technical) {
        const details = document.getElementById(&#39;errorTechnicalDetails&#39;);
        const grid = document.getElementById(&#39;errorTechnicalGrid&#39;);
        const data = technical &amp;&amp; typeof technical === &#39;object&#39;
          ? technical
          : {};

        const labels = {
          errorCode: &#39;분류 코드&#39;,
          httpStatus: &#39;HTTP 상태&#39;,
          reason: &#39;API 사유 코드&#39;,
          resource: &#39;요청 자원&#39;,
          videoId: &#39;영상 ID&#39;,
          context: &#39;실행 단계&#39;
        };

        grid.innerHTML = &#39;&#39;;

        Object.keys(labels).forEach(function(key) {
          if (
            data[key] === undefined ||
            data[key] === null ||
            data[key] === &#39;&#39;
          ) {
            return;
          }

          const label = document.createElement(&#39;div&#39;);
          label.className = &#39;technical-label&#39;;
          label.textContent = labels[key];

          const value = document.createElement(&#39;div&#39;);
          value.className = &#39;technical-value&#39;;
          value.textContent = String(data[key]);

          grid.appendChild(label);
          grid.appendChild(value);
        });

        details.classList.toggle(&#39;hidden&#39;, grid.children.length === 0);
        details.open = false;
      }

      function closeErrorModal() {
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);

        if (backdrop.classList.contains(&#39;hidden&#39;)) {
          return;
        }

        backdrop.classList.add(&#39;hidden&#39;);
        document.body.style.overflow = &#39;&#39;;
        pendingFocusTarget = &#39;&#39;;
      }

      function handleErrorModalPrimaryAction() {
        const focusTarget = pendingFocusTarget;
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);

        backdrop.classList.add(&#39;hidden&#39;);
        document.body.style.overflow = &#39;&#39;;
        pendingFocusTarget = &#39;&#39;;

        focusErrorTarget(focusTarget);
      }

      function focusErrorTarget(targetId) {
        if (!targetId) {
          return;
        }

        if (targetId === &#39;apiKey&#39;) {
          document.getElementById(&#39;apiSavedView&#39;).classList.add(&#39;hidden&#39;);
          document.getElementById(&#39;apiMissingView&#39;).classList.remove(&#39;hidden&#39;);
        }

        const target = document.getElementById(targetId);

        if (target) {
          window.setTimeout(function() {
            target.focus();

            if (typeof target.select === &#39;function&#39;) {
              target.select();
            }
          }, 0);
        }
      }

      function formatNumber(value) {
        return Number(value || 0).toLocaleString(&#39;ko-KR&#39;);
      }

      function escapeHtml(value) {
        return String(value || &#39;&#39;)
          .replace(/&amp;/g, &#39;&amp;amp;&#39;)
          .replace(/&lt;/g, &#39;&amp;lt;&#39;)
          .replace(/&gt;/g, &#39;&amp;gt;&#39;)
          .replace(/&quot;/g, &#39;&amp;quot;&#39;)
          .replace(/&#39;/g, &#39;&amp;#039;&#39;);
      }
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</pre></section><section><h3>Youtube Data API v3 KEY</h3><pre>AIzaSyDETqF5gzbFh3DG4U0N-rjT1yK2asM-1X4</pre></section></section>]]></description></item><item><title>2026년 인공지능 활용 선도교사 연수 (충남 천안 - 8.2.)</title><link>https://민기.한국/training/U1scquRDrBeE7hpQh3OB</link><guid isPermaLink="true">https://민기.한국/training/U1scquRDrBeE7hpQh3OB</guid><pubDate>Sun, 02 Aug 2026 05:08:25 GMT</pubDate><description><![CDATA[<h1>2026년 인공지능 활용 선도교사 연수 (충남 천안 - 8.2.)</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-08-02</dd><dt>장소</dt><dd>천안 신라스테이</dd></dl><section><h2>(1) 음성 데이터</h2><h3>관련 링크</h3><ul><li><a href="https://drive.google.com/file/u/0/d/1bTxR-DyK3I4i1ZRCdyVb_lWFEobrtOKe/view?pli=1">실습용 녹음 파일</a></li></ul></section><section><h2>(2) 댓글 데이터</h2><section><h3>Code.gs</h3><pre>const YT_CFG = Object.freeze({
  API_BASE: &#39;https://www.googleapis.com/youtube/v3&#39;,

  API_KEY_PROP: &#39;YT_API_KEY&#39;,
  STATE_PROP: &#39;YT_COMMENT_COLLECTOR_STATE&#39;,
  LAST_RESULT_PROP: &#39;YT_COMMENT_COLLECTOR_LAST_RESULT&#39;,
  CANCEL_REQUEST_PROP: &#39;YT_COMMENT_COLLECTOR_CANCEL_REQUEST&#39;,

  OUTPUT_SHEET_PREFIX: &#39;시트&#39;,
  INTERNAL_SHEET: &#39;_댓글수집_내부&#39;,
  LEGACY_LOG_SHEET: &#39;수집로그&#39;,

  CONTINUE_HANDLER: &#39;continueCommentCollection&#39;,
  STRUCTURED_ERROR_PREFIX: &#39;YT_STRUCTURED_ERROR::&#39;,
  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: [
    &#39;번호&#39;,
    &#39;채널명&#39;,
    &#39;영상 제목&#39;,
    &#39;댓글 작성자&#39;,
    &#39;댓글 내용&#39;,
    &#39;좋아요 수&#39;,
    &#39;작성 시각&#39;,
    &#39;글자수&#39;
  ],

  // 숨김 내부 시트의 열 구조입니다.
  INTERNAL_HEADERS: [
    &#39;comment_id&#39;,
    &#39;parent_comment_id&#39;,
    &#39;display_number&#39;,
    &#39;comment_type&#39;,
    &#39;reply_status&#39;,
    &#39;next_reply_index&#39;,
    &#39;total_reply_count&#39;,
    &#39;reply_page_token&#39;
  ],

  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(&#39;YouTube 댓글 수집&#39;)
    .addItem(&#39;사이드바 열기&#39;, &#39;showYouTubeCommentSidebar&#39;)
    .addSeparator()
    .addItem(&#39;중단 지점부터 실행&#39;, &#39;continueCommentCollection&#39;)
    .addItem(&#39;진행 중인 수집 취소&#39;, &#39;cancelCommentCollection&#39;)
    .addToUi();
}


/**
 * 오른쪽 사이드바를 엽니다.
 */
function showYouTubeCommentSidebar() {
  const html = HtmlService
    .createHtmlOutputFromFile(&#39;Sidebar&#39;)
    .setTitle(&#39;YouTube 댓글 수집기&#39;);

  SpreadsheetApp.getUi().showSidebar(html);
}


/**
 * 사이드바 초기 상태를 반환합니다.
 */
function getSidebarState() {
  const props = getUserProperties_();
  const apiKey = props.getProperty(YT_CFG.API_KEY_PROP) || &#39;&#39;;
  const activeState = loadState_();
  const lastResult = loadLastResult_();
  const cancelRequested =
    props.getProperty(YT_CFG.CANCEL_REQUEST_PROP) === &#39;true&#39;;

  const progress = activeState
    ? buildProgressForClient_(activeState, &#39;RUNNING&#39;)
    : lastResult
      ? buildProgressForClient_(lastResult, lastResult.status || &#39;COMPLETED&#39;)
      : null;

  if (progress &amp;&amp; activeState &amp;&amp; cancelRequested) {
    progress.stage = &#39;취소 처리 중&#39;;
    progress.message = &#39;현재 실행 구간이 끝나는 즉시 안전하게 중단됩니다.&#39;;
  }

  return {
    apiKeyConfigured: Boolean(apiKey),
    apiKeyMask: apiKey ? maskApiKey_(apiKey) : &#39;&#39;,
    running: Boolean(activeState),
    cancelRequested: cancelRequested,
    progress: progress
  };
}


/**
 * API 키를 실제 YouTube Data API 요청으로 검증한 뒤 사용자별 속성에 저장합니다.
 */
function saveApiKey(apiKey) {
  const cleanedKey = String(apiKey || &#39;&#39;).trim();

  if (!cleanedKey) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_REQUIRED&#39;,
      &#39;API 키를 입력해야 합니다.&#39;,
      &#39;Google Cloud에서 발급한 YouTube Data API 키가 입력되지 않았습니다.&#39;,
      [&#39;API 키 입력란이 비어 있습니다.&#39;],
      [
        &#39;Google Cloud Console에서 API 키를 발급합니다.&#39;,
        &#39;API 키 전체를 사이드바 입력란에 붙여 넣습니다.&#39;,
        &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 입력&#39;
      }
    ));
  }

  if (cleanedKey.length &lt; 20) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_FORMAT_INVALID&#39;,
      &#39;API 키 형식을 확인해야 합니다.&#39;,
      &#39;입력한 문자열이 일반적인 Google API 키 길이보다 짧습니다.&#39;,
      [
        &#39;API 키 일부만 복사했을 수 있습니다.&#39;,
        &#39;프로젝트 ID나 다른 값을 입력했을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;키 유형이 ‘API 키’인지 확인합니다.&#39;,
        &#39;API 키 값을 처음부터 끝까지 다시 복사하여 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 다시 입력&#39;
      }
    ));
  }

  validateApiKey_(cleanedKey);

  const props = getUserProperties_();
  props.setProperty(YT_CFG.API_KEY_PROP, cleanedKey);

  // 이전 API 키 오류 안내가 새 키 저장 뒤 다시 나타나지 않도록 정리합니다.
  const previous = loadLastResult_();
  if (previous &amp;&amp; previous.status === &#39;ERROR&#39;) {
    props.deleteProperty(YT_CFG.LAST_RESULT_PROP);
  }

  return getSidebarState();
}


/**
 * 저장된 API 키를 삭제합니다.
 */
function deleteApiKey() {
  if (loadState_()) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_DELETE_BLOCKED&#39;,
      &#39;수집 중에는 API 키를 삭제할 수 없습니다.&#39;,
      &#39;현재 댓글 수집 작업이 저장된 API 키를 사용하고 있습니다.&#39;,
      [&#39;진행 중인 작업이 존재합니다.&#39;],
      [
        &#39;현재 수집을 완료하거나 취소합니다.&#39;,
        &#39;수집 상태가 종료된 뒤 API 키를 삭제합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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_(
        &#39;SPREADSHEET_NOT_FOUND&#39;,
        &#39;댓글을 저장할 스프레드시트를 찾지 못했습니다.&#39;,
        &#39;현재 Apps Script가 스프레드시트와 연결된 상태인지 확인해야 합니다.&#39;,
        [
          &#39;독립 실행형 Apps Script 프로젝트에서 코드를 실행했을 수 있습니다.&#39;,
          &#39;스프레드시트가 닫히거나 접근 권한이 변경되었을 수 있습니다.&#39;
        ],
        [
          &#39;댓글을 저장할 Google 스프레드시트를 엽니다.&#39;,
          &#39;‘확장 프로그램 → Apps Script’에서 이 코드를 실행합니다.&#39;,
          &#39;스프레드시트를 새로고침하고 사이드바를 다시 엽니다.&#39;
        ],
        { actionLabel: &#39;확인&#39; }
      ));
    }

    // 영상과 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: &#39;RUNNING&#39;,
      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: &#39;&#39;,
      threadDone: false,

      currentReplyParentId: &#39;&#39;,
      nextReplyPageToken: &#39;&#39;,

      topCount: 0,
      replyCount: 0,
      threadPages: 0,
      replyPages: 0,
      apiCalls: 1,

      startedAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      message: outputSheet.getName() + &#39;에서 수집을 시작했습니다.&#39;,
      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 &lt; 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 || &#39;&#39;;
        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 = &#39;실행 시간을 고려하여 진행 상태를 저장했습니다.&#39;;
    saveState_(state);
    scheduleContinuation_();

  } catch (error) {
    deleteContinuationTriggers_();

    const errorInfo = normalizeErrorInfo_(error, &#39;댓글 수집&#39;);

    if (errorInfo.code === &#39;API_KEY_INVALID&#39;) {
      getUserProperties_().deleteProperty(YT_CFG.API_KEY_PROP);
    }

    if (commentsSheet) {
      try {
        finalizeCommentsSheet_(commentsSheet);
      } catch (formatError) {
        console.error(formatError);
      }
    }

    if (state) {
      state.status = &#39;ERROR&#39;;
      state.updatedAt = new Date().toISOString();
      state.message = errorInfo.summary;
      state.error = errorInfo;
      saveLastResult_(state, &#39;ERROR&#39;, 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, &#39;true&#39;);
  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 &lt;= 0) {
    state.threadDone = true;
    state.nextThreadPageToken = &#39;&#39;;
    return;
  }

  const params = {
    part: state.includeReplies ? &#39;snippet,replies&#39; : &#39;snippet&#39;,
    videoId: state.videoId,
    maxResults: Math.min(100, remaining),
    textFormat: &#39;plainText&#39;
  };

  if (state.nextThreadPageToken) {
    params.pageToken = state.nextThreadPageToken;
  }

  const data = youtubeGet_(&#39;commentThreads&#39;, 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 &gt;= 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 = &#39;DONE&#39;;
    let nextReplyIndex = 1;

    commentRows.push(
      commentToOutputRow_(state, topComment, topNumber)
    );

    batchIds.add(topCommentId);
    topAdded += 1;

    const inlineReplies =
      thread.replies &amp;&amp; Array.isArray(thread.replies.comments)
        ? thread.replies.comments
        : [];

    if (state.includeReplies &amp;&amp; totalReplyCount &gt; 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 + &#39;_&#39; + nextReplyIndex;
          nextReplyIndex += 1;

          commentRows.push(
            commentToOutputRow_(state, reply, displayNumber)
          );

          internalRows.push(
            buildInternalRow_(
              replyId,
              topCommentId,
              displayNumber,
              &#39;reply&#39;,
              &#39;&#39;,
              &#39;&#39;,
              &#39;&#39;,
              &#39;&#39;
            )
          );

          batchIds.add(replyId);
          replyAdded += 1;
        });
      } else {
        // commentThreads.list의 replies에는 전체 답글이 보장되지 않으므로
        // comments.list로 전체 답글을 다시 가져옵니다.
        replyStatus = &#39;PENDING&#39;;
        nextReplyIndex = 1;
      }
    }

    internalRows.push(
      buildInternalRow_(
        topCommentId,
        &#39;&#39;,
        topNumber,
        &#39;topLevel&#39;,
        replyStatus,
        nextReplyIndex,
        totalReplyCount,
        &#39;&#39;
      )
    );
  });

  appendCollectedRecords_(
    commentsSheet,
    internalSheet,
    commentRows,
    internalRows
  );

  batchIds.forEach(function(id) {
    existingIds.add(id);
  });

  state.topCount += topAdded;
  state.replyCount += replyAdded;

  const reachedTarget = state.topCount &gt;= state.targetTopLevelCount;
  const hasNextPage = Boolean(data.nextPageToken);

  state.nextThreadPageToken =
    !reachedTarget &amp;&amp; hasNextPage
      ? data.nextPageToken
      : &#39;&#39;;

  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 = &#39;&#39;;
    state.nextReplyPageToken = &#39;&#39;;
    return;
  }

  const params = {
    part: &#39;snippet&#39;,
    parentId: state.currentReplyParentId,
    maxResults: 100,
    textFormat: &#39;plainText&#39;
  };

  const pageToken =
    state.nextReplyPageToken || topRecord.replyPageToken || &#39;&#39;;

  if (pageToken) {
    params.pageToken = pageToken;
  }

  const data = youtubeGet_(&#39;comments&#39;, 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) + &#39;_&#39; + nextReplyIndex;

    nextReplyIndex += 1;

    commentRows.push(
      commentToOutputRow_(state, reply, displayNumber)
    );

    internalRows.push(
      buildInternalRow_(
        replyId,
        state.currentReplyParentId,
        displayNumber,
        &#39;reply&#39;,
        &#39;&#39;,
        &#39;&#39;,
        &#39;&#39;,
        &#39;&#39;
      )
    );

    batchIds.add(replyId);
    replyAdded += 1;
  });

  appendCollectedRecords_(
    commentsSheet,
    internalSheet,
    commentRows,
    internalRows
  );

  batchIds.forEach(function(id) {
    existingIds.add(id);
  });

  state.replyCount += replyAdded;

  const nextPageToken = data.nextPageToken || &#39;&#39;;
  const nextStatus = nextPageToken ? &#39;IN_PROGRESS&#39; : &#39;DONE&#39;;

  updateTopReplyProgress_(
    internalSheet,
    topRecord.rowNumber,
    nextStatus,
    nextReplyIndex,
    nextPageToken
  );

  if (nextPageToken) {
    state.nextReplyPageToken = nextPageToken;
  } else {
    state.currentReplyParentId = &#39;&#39;;
    state.nextReplyPageToken = &#39;&#39;;
  }

  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(&#39;IN_PROGRESS&#39;)
    .matchEntireCell(true)
    .useRegularExpression(false)
    .findNext();

  if (!found) {
    found = statusRange
      .createTextFinder(&#39;PENDING&#39;)
      .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] === &#39;PENDING&#39;) {
    internalSheet
      .getRange(rowNumber, YT_CFG.INTERNAL_COL.REPLY_STATUS)
      .setValue(&#39;IN_PROGRESS&#39;);
  }

  return {
    rowNumber: rowNumber,
    commentId: String(values[YT_CFG.INTERNAL_COL.COMMENT_ID - 1] || &#39;&#39;),
    displayNumber: String(
      values[YT_CFG.INTERNAL_COL.DISPLAY_NUMBER - 1] || &#39;&#39;
    ),
    nextReplyIndex: Number(
      values[YT_CFG.INTERNAL_COL.NEXT_REPLY_INDEX - 1] || 1
    ),
    pageToken: String(
      values[YT_CFG.INTERNAL_COL.REPLY_PAGE_TOKEN - 1] || &#39;&#39;
    )
  };
}


/**
 * 댓글 1건을 사용자가 보는 8열 행으로 변환합니다.
 */
function commentToOutputRow_(state, comment, displayNumber) {
  const snippet = (comment &amp;&amp; comment.snippet) || {};
  const text = String(snippet.textDisplay || &#39;&#39;);

  return [
    String(displayNumber),
    safeCellText_(state.videoChannelTitle || &#39;&#39;),
    safeCellText_(state.videoTitle || &#39;&#39;),
    safeCellText_(snippet.authorDisplayName || &#39;&#39;),
    safeCellText_(text),
    Number(snippet.likeCount || 0),
    apiDateToSheetValue_(snippet.publishedAt),
    calculateCommentBytes_(text)
  ];
}


/**
 * 숨김 내부 시트의 행을 만듭니다.
 */
function buildInternalRow_(
  commentId,
  parentCommentId,
  displayNumber,
  commentType,
  replyStatus,
  nextReplyIndex,
  totalReplyCount,
  replyPageToken
) {
  return [
    safeCellText_(commentId || &#39;&#39;),
    safeCellText_(parentCommentId || &#39;&#39;),
    String(displayNumber || &#39;&#39;),
    String(commentType || &#39;&#39;),
    String(replyStatus || &#39;&#39;),
    nextReplyIndex === &#39;&#39; ? &#39;&#39; : Number(nextReplyIndex || 1),
    totalReplyCount === &#39;&#39; ? &#39;&#39; : Number(totalReplyCount || 0),
    safeCellText_(replyPageToken || &#39;&#39;)
  ];
}


/**
 * 댓글 행과 내부 관리 행을 각각 일괄 기록합니다.
 */
function appendCollectedRecords_(
  commentsSheet,
  internalSheet,
  commentRows,
  internalRows
) {
  if (commentRows.length !== internalRows.length) {
    throwStructuredError_(buildErrorInfo_(
      &#39;INTERNAL_ROW_MISMATCH&#39;,
      &#39;댓글 저장 구조를 확인해야 합니다.&#39;,
      &#39;댓글 행과 내부 관리 행의 개수가 일치하지 않습니다.&#39;,
      [&#39;수집 상태를 기록하는 내부 데이터가 일시적으로 어긋났습니다.&#39;],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;사이드바에서 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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 &lt;= 1) {
    return null;
  }

  const found = internalSheet
    .getRange(2, YT_CFG.INTERNAL_COL.COMMENT_ID, lastRow - 1, 1)
    .createTextFinder(String(commentId || &#39;&#39;))
    .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] || &#39;&#39;),
    displayNumber: String(
      values[YT_CFG.INTERNAL_COL.DISPLAY_NUMBER - 1] || &#39;&#39;
    ),
    replyStatus: String(
      values[YT_CFG.INTERNAL_COL.REPLY_STATUS - 1] || &#39;&#39;
    ),
    nextReplyIndex: Number(
      values[YT_CFG.INTERNAL_COL.NEXT_REPLY_INDEX - 1] || 1
    ),
    replyPageToken: String(
      values[YT_CFG.INTERNAL_COL.REPLY_PAGE_TOKEN - 1] || &#39;&#39;
    )
  };
}


/**
 * 최상위 댓글의 답글 수집 진행 정보를 갱신합니다.
 */
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 || &#39;&#39;
    ]]);
}


/**
 * 완료 처리
 */
function finishCollection_(ss, commentsSheet, state) {
  state.status = &#39;COMPLETED&#39;;
  state.updatedAt = new Date().toISOString();
  state.finishedAt = new Date().toISOString();
  state.message = state.topCount &lt; state.targetTopLevelCount
    ? &#39;댓글 &#39; +
      state.topCount +
      &#39;개와 대댓글 &#39; +
      state.replyCount +
      &#39;개를 수집했습니다.&#39;
    : &#39;댓글 수집을 완료했습니다.&#39;;
  state.error = null;

  finalizeCommentsSheet_(commentsSheet);
  saveLastResult_(state, &#39;COMPLETED&#39;, 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) === &#39;true&#39;;
}


/**
 * 취소 처리
 */
function finalizeCancellation_(ss, commentsSheet, state) {
  const message = &#39;사용자가 수집을 취소했습니다. 이미 저장된 댓글은 유지됩니다.&#39;;

  state.status = &#39;CANCELLED&#39;;
  state.updatedAt = new Date().toISOString();
  state.finishedAt = new Date().toISOString();
  state.message = message;
  state.error = null;

  finalizeCommentsSheet_(commentsSheet);
  saveLastResult_(state, &#39;CANCELLED&#39;, 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_(
    &#39;videos&#39;,
    {
      part: &#39;id&#39;,
      id: &#39;jNQXAC9IVRw&#39;
    },
    apiKey
  );
}


/**
 * 영상 메타데이터를 조회합니다.
 */
function getVideoMetadataWithKey_(videoId, apiKey) {
  const data = youtubeGetWithKey_(
    &#39;videos&#39;,
    {
      part: &#39;snippet,statistics&#39;,
      id: videoId
    },
    apiKey
  );

  if (!Array.isArray(data.items) || data.items.length === 0) {
    throwStructuredError_(buildErrorInfo_(
      &#39;VIDEO_NOT_FOUND&#39;,
      &#39;영상 정보를 찾을 수 없습니다.&#39;,
      &#39;입력한 영상 주소에 해당하는 공개 영상을 YouTube Data API에서 조회하지 못했습니다.&#39;,
      [
        &#39;영상 주소나 11자리 영상 ID가 정확하지 않을 수 있습니다.&#39;,
        &#39;영상이 삭제되었거나 비공개 상태일 수 있습니다.&#39;,
        &#39;연령·지역·계정 권한에 따라 접근이 제한된 영상일 수 있습니다.&#39;
      ],
      [
        &#39;브라우저에서 해당 영상 주소가 정상적으로 열리는지 확인합니다.&#39;,
        &#39;주소 표시줄의 영상 URL 전체를 다시 복사합니다.&#39;,
        &#39;사이드바의 영상 주소를 교체한 뒤 수집을 다시 시작합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: { videoId: videoId }
      }
    ));
  }

  const item = data.items[0];
  const snippet = item.snippet || {};
  const statistics = item.statistics || {};

  return {
    title: snippet.title || &#39;&#39;,
    channelId: snippet.channelId || &#39;&#39;,
    channelTitle: snippet.channelTitle || &#39;&#39;,
    commentCount: statistics.commentCount || &#39;&#39;
  };
}


/**
 * 저장된 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 &amp;&amp; value !== null &amp;&amp; value !== &#39;&#39;;
    })
    .map(function(key) {
      return encodeURIComponent(key) + &#39;=&#39; + encodeURIComponent(queryParams[key]);
    })
    .join(&#39;&amp;&#39;);

  const url = YT_CFG.API_BASE + &#39;/&#39; + resource + &#39;?&#39; + query;
  const transientCodes = [429, 500, 502, 503, 504];

  for (let attempt = 0; attempt &lt; 3; attempt += 1) {
    let response;

    try {
      response = UrlFetchApp.fetch(url, {
        method: &#39;get&#39;,
        muteHttpExceptions: true,
        headers: { Accept: &#39;application/json&#39; }
      });
    } catch (networkError) {
      if (attempt &lt; 2) {
        Utilities.sleep(Math.pow(2, attempt) * 1000);
        continue;
      }

      throwStructuredError_(buildErrorInfo_(
        &#39;NETWORK_REQUEST_FAILED&#39;,
        &#39;YouTube 서버에 연결하지 못했습니다.&#39;,
        &#39;Apps Script에서 YouTube Data API로 보내는 외부 요청이 완료되지 않았습니다.&#39;,
        [
          &#39;Google 서비스 또는 네트워크에 일시적인 장애가 발생했을 수 있습니다.&#39;,
          &#39;Apps Script의 외부 요청 권한이 승인되지 않았을 수 있습니다.&#39;
        ],
        [
          &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
          &#39;Apps Script 권한 승인 화면이 나타나면 요청된 권한을 허용합니다.&#39;,
          &#39;Apps Script의 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;
        ],
        {
          actionLabel: &#39;확인&#39;,
          technical: {
            resource: resource,
            originalMessage: String(networkError &amp;&amp; networkError.message || networkError)
          }
        }
      ));
    }

    const statusCode = response.getResponseCode();
    const responseText = response.getContentText();

    if (statusCode &gt;= 200 &amp;&amp; statusCode &lt; 300) {
      return responseText ? JSON.parse(responseText) : {};
    }

    if (transientCodes.indexOf(statusCode) &gt;= 0 &amp;&amp; attempt &lt; 2) {
      Utilities.sleep(Math.pow(2, attempt) * 1000);
      continue;
    }

    const parsedError = parseYouTubeErrorResponse_(responseText);

    throwStructuredError_(classifyYouTubeApiError_(
      statusCode,
      parsedError.reason,
      parsedError.message,
      resource
    ));
  }

  throwStructuredError_(buildErrorInfo_(
    &#39;YOUTUBE_REQUEST_REPEATED_FAILURE&#39;,
    &#39;YouTube API 요청이 반복적으로 실패했습니다.&#39;,
    &#39;일시적인 서버 장애가 재시도 후에도 계속되었습니다.&#39;,
    [&#39;YouTube 또는 Google 네트워크 서비스가 불안정할 수 있습니다.&#39;],
    [
      &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
      &#39;반복되면 Apps Script의 ‘실행’ 기록을 확인합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: { resource: resource } }
  ));
}


function parseYouTubeErrorResponse_(responseText) {
  let reason = &#39;&#39;;
  let message = String(responseText || &#39;&#39;);

  try {
    const parsed = JSON.parse(responseText);

    if (parsed.error) {
      message = parsed.error.message || message;

      if (Array.isArray(parsed.error.errors) &amp;&amp; parsed.error.errors[0]) {
        reason = parsed.error.errors[0].reason || &#39;&#39;;
      }

      if (!reason &amp;&amp; parsed.error.status) {
        reason = parsed.error.status;
      }
    }
  } catch (parseError) {
    // JSON이 아닌 오류 본문은 문자열 그대로 분류합니다.
  }

  return {
    reason: String(reason || &#39;&#39;),
    message: String(message || &#39;&#39;)
  };
}


/**
 * YouTube API 오류를 한국어 모달용 정보로 분류합니다.
 */
function classifyYouTubeApiError_(statusCode, reason, message, resource) {
  const reasonText = String(reason || &#39;&#39;);
  const messageText = String(message || &#39;&#39;);
  const combined = (reasonText + &#39; &#39; + messageText).toLowerCase();
  const technical = {
    httpStatus: statusCode,
    apiReason: reasonText,
    resource: resource,
    originalMessage: messageText
  };

  if (
    combined.indexOf(&#39;keyinvalid&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api key not valid&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api_key_invalid&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_INVALID&#39;,
      &#39;API 키를 사용할 수 없습니다.&#39;,
      &#39;입력한 API 키가 정확하지 않거나 삭제·재발급되어 현재 요청에 사용할 수 없습니다.&#39;,
      [
        &#39;API 키 일부가 누락되었을 수 있습니다.&#39;,
        &#39;Google Cloud에서 기존 키가 삭제되거나 재발급되었을 수 있습니다.&#39;,
        &#39;API 키가 아닌 다른 프로젝트 값을 입력했을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;키 유형이 ‘API 키’인지 확인합니다.&#39;,
        &#39;API 키 전체를 다시 복사합니다.&#39;,
        &#39;사이드바에서 API 키를 다시 입력하고 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;accessnotconfigured&#39;) &gt;= 0 ||
    combined.indexOf(&#39;access not configured&#39;) &gt;= 0 ||
    combined.indexOf(&#39;service_disabled&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api_disabled&#39;) &gt;= 0 ||
    combined.indexOf(&#39;has not been used in project&#39;) &gt;= 0 ||
    combined.indexOf(&#39;it is disabled&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_API_NOT_ENABLED&#39;,
      &#39;YouTube Data API v3가 활성화되어 있지 않습니다.&#39;,
      &#39;API 키가 속한 Google Cloud 프로젝트에서 YouTube Data API v3 사용 설정을 켜야 합니다.&#39;,
      [
        &#39;API 키를 만든 프로젝트에서 YouTube Data API v3를 아직 활성화하지 않았습니다.&#39;,
        &#39;API를 활성화한 프로젝트와 API 키를 발급한 프로젝트가 다를 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console에서 API 키를 만든 프로젝트를 선택합니다.&#39;,
        &#39;‘API 및 서비스 → 라이브러리’로 이동합니다.&#39;,
        &#39;‘YouTube Data API v3’를 검색하고 ‘사용’을 누릅니다.&#39;,
        &#39;설정 반영을 위해 1~5분 기다린 뒤 API 키를 다시 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;YouTube Data API 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/library/youtube.googleapis.com&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;iprefererblocked&#39;) &gt;= 0 ||
    combined.indexOf(&#39;referer&#39;) &gt;= 0 &amp;&amp; combined.indexOf(&#39;blocked&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api key restrictions&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_APPLICATION_RESTRICTION&#39;,
      &#39;API 키의 애플리케이션 제한이 Apps Script 요청을 차단했습니다.&#39;,
      &#39;현재 키에 설정된 웹사이트·IP 제한이 Apps Script의 UrlFetch 요청 방식과 맞지 않습니다.&#39;,
      [
        &#39;HTTP 리퍼러 제한 또는 특정 IP 주소 제한이 적용되어 있을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;사용 중인 API 키의 애플리케이션 제한을 확인합니다.&#39;,
        &#39;Apps Script에서 사용할 수 있는 제한 방식으로 조정합니다.&#39;,
        &#39;API 제한은 ‘YouTube Data API v3’로 유지합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 제한 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;accessnotconfigured&#39;) &lt; 0 &amp;&amp;
    (
      combined.indexOf(&#39;forbidden&#39;) &gt;= 0 &amp;&amp;
      combined.indexOf(&#39;api&#39;) &gt;= 0 &amp;&amp;
      combined.indexOf(&#39;key&#39;) &gt;= 0
    )
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_API_RESTRICTION&#39;,
      &#39;API 키의 API 제한 설정을 확인해야 합니다.&#39;,
      &#39;현재 API 키가 YouTube Data API v3 호출을 허용하지 않을 수 있습니다.&#39;,
      [
        &#39;API 키의 ‘API 제한’ 목록에서 YouTube Data API v3가 빠져 있을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;사용 중인 API 키를 선택합니다.&#39;,
        &#39;‘API 제한’에서 YouTube Data API v3를 허용합니다.&#39;,
        &#39;저장 후 1~5분 뒤 다시 실행합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 제한 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;quotaexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;dailylimitexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;daily limit&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_QUOTA_EXCEEDED&#39;,
      &#39;YouTube API의 오늘 할당량을 모두 사용했습니다.&#39;,
      &#39;현재 Google Cloud 프로젝트에서 오늘 사용할 수 있는 YouTube Data API 할당량이 남아 있지 않습니다.&#39;,
      [&#39;같은 프로젝트의 다른 프로그램에서도 YouTube API 할당량을 사용했을 수 있습니다.&#39;],
      [
        &#39;Google Cloud Console에서 YouTube Data API 사용량을 확인합니다.&#39;,
        &#39;일일 할당량이 갱신된 뒤 다시 실행합니다.&#39;,
        &#39;필요하면 다음 수집의 댓글 개수를 줄입니다.&#39;
      ],
      {
        helpLabel: &#39;YouTube API 할당량 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas&#39;,
        actionLabel: &#39;확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;ratelimitexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;userratelimitexceeded&#39;) &gt;= 0 ||
    statusCode === 429
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_RATE_LIMIT&#39;,
      &#39;짧은 시간에 API 요청이 많이 발생했습니다.&#39;,
      &#39;YouTube Data API가 일시적으로 요청 속도를 제한했습니다.&#39;,
      [&#39;여러 수집 작업을 연속으로 실행했을 수 있습니다.&#39;],
      [
        &#39;현재 작업이 자동 재개되는지 확인합니다.&#39;,
        &#39;몇 분 뒤 다시 실행합니다.&#39;,
        &#39;동시에 여러 스프레드시트에서 같은 API 키를 사용하고 있다면 실행 횟수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (combined.indexOf(&#39;commentsdisabled&#39;) &gt;= 0) {
    return buildErrorInfo_(
      &#39;COMMENTS_DISABLED&#39;,
      &#39;이 영상에서는 댓글을 수집할 수 없습니다.&#39;,
      &#39;영상 게시자가 댓글 기능을 사용하지 않도록 설정했습니다.&#39;,
      [&#39;영상의 댓글 기능이 꺼져 있거나 YouTube 정책에 따라 제한되었습니다.&#39;],
      [
        &#39;영상 페이지에서 댓글 영역이 표시되는지 확인합니다.&#39;,
        &#39;댓글이 허용된 다른 영상 주소를 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;videonotfound&#39;) &gt;= 0 ||
    combined.indexOf(&#39;video not found&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;VIDEO_NOT_FOUND&#39;,
      &#39;영상 정보를 찾을 수 없습니다.&#39;,
      &#39;영상 주소가 잘못되었거나 영상이 삭제·비공개 상태일 수 있습니다.&#39;,
      [
        &#39;영상 ID가 정확하지 않을 수 있습니다.&#39;,
        &#39;영상 공개 상태가 변경되었을 수 있습니다.&#39;
      ],
      [
        &#39;브라우저에서 영상 주소가 정상적으로 열리는지 확인합니다.&#39;,
        &#39;영상 URL 전체를 다시 복사해 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (statusCode &gt;= 500) {
    return buildErrorInfo_(
      &#39;YOUTUBE_SERVER_ERROR&#39;,
      &#39;YouTube 서버에서 일시적인 오류가 발생했습니다.&#39;,
      &#39;YouTube Data API 서버가 현재 요청을 정상적으로 처리하지 못했습니다.&#39;,
      [&#39;YouTube 측의 일시적인 서비스 장애일 수 있습니다.&#39;],
      [
        &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
        &#39;진행 중인 수집이라면 자동 재개 여부를 확인합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (statusCode === 403) {
    return buildErrorInfo_(
      &#39;YOUTUBE_ACCESS_FORBIDDEN&#39;,
      &#39;YouTube 데이터에 접근할 수 없습니다.&#39;,
      &#39;API 키 설정, 영상 공개 상태 또는 Google Cloud 프로젝트 권한을 확인해야 합니다.&#39;,
      [
        &#39;API 키 제한 설정이 요청을 차단했을 수 있습니다.&#39;,
        &#39;영상이 공개 API에서 접근할 수 없는 상태일 수 있습니다.&#39;
      ],
      [
        &#39;API 키의 API 제한과 애플리케이션 제한을 확인합니다.&#39;,
        &#39;영상이 공개 상태인지 확인합니다.&#39;,
        &#39;Google Cloud에서 YouTube Data API v3가 활성화되어 있는지 확인합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (statusCode === 400) {
    return buildErrorInfo_(
      &#39;YOUTUBE_BAD_REQUEST&#39;,
      &#39;YouTube API 요청 형식을 확인해야 합니다.&#39;,
      &#39;영상 주소 또는 요청 매개변수 중 하나를 YouTube API가 처리하지 못했습니다.&#39;,
      [&#39;영상 주소가 올바르지 않거나 API 요청 값이 허용 범위를 벗어났을 수 있습니다.&#39;],
      [
        &#39;영상 주소를 다시 확인합니다.&#39;,
        &#39;수집 개수와 정렬 기준을 확인합니다.&#39;,
        &#39;같은 문제가 계속되면 새 수집으로 다시 시작합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;입력값 확인&#39;,
        technical: technical
      }
    );
  }

  return buildErrorInfo_(
    &#39;YOUTUBE_API_ERROR&#39;,
    &#39;YouTube API 요청을 완료하지 못했습니다.&#39;,
    &#39;YouTube Data API가 요청을 거부하거나 처리하지 못했습니다.&#39;,
    [&#39;API 키, 영상 공개 상태, 할당량 또는 네트워크 상태를 확인해야 합니다.&#39;],
    [
      &#39;API 키 설정과 YouTube Data API 활성화 여부를 확인합니다.&#39;,
      &#39;영상 주소가 공개 영상인지 확인합니다.&#39;,
      &#39;잠시 뒤 다시 실행합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: technical }
  );
}


/**
 * 영상 URL 또는 11자리 영상 ID에서 videoId를 추출합니다.
 */
function extractVideoId_(input) {
  const text = String(input || &#39;&#39;).trim();

  if (/^[A-Za-z0-9_-]{11}$/.test(text)) {
    return text;
  }

  const patterns = [
    /[?&amp;]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 &lt; patterns.length; i += 1) {
    const match = text.match(patterns[i]);

    if (match) {
      return match[1];
    }
  }

  throwStructuredError_(buildErrorInfo_(
    &#39;INVALID_VIDEO_URL&#39;,
    &#39;YouTube 영상 주소를 확인해야 합니다.&#39;,
    &#39;입력한 값에서 11자리 YouTube 영상 ID를 찾지 못했습니다.&#39;,
    [
      &#39;영상 주소 일부가 빠졌을 수 있습니다.&#39;,
      &#39;채널·재생목록 주소를 입력했을 수 있습니다.&#39;
    ],
    [
      &#39;YouTube에서 수집할 영상을 엽니다.&#39;,
      &#39;브라우저 주소 표시줄의 영상 URL 전체를 복사합니다.&#39;,
      &#39;사이드바의 영상 주소 입력란에 붙여 넣습니다.&#39;
    ],
    {
      focusTarget: &#39;videoUrl&#39;,
      actionLabel: &#39;영상 주소 다시 입력&#39;
    }
  ));
}


/**
 * 사이드바 입력값을 검증합니다.
 */
function normalizeSidebarInput_(formData) {
  const form = formData || {};
  const videoUrl = String(form.videoUrl || &#39;&#39;).trim();
  const limit = parseInt(form.limit, 10);

  if (!videoUrl) {
    throwStructuredError_(buildErrorInfo_(
      &#39;VIDEO_URL_REQUIRED&#39;,
      &#39;YouTube 영상 주소를 입력해야 합니다.&#39;,
      &#39;수집 대상 영상 주소가 비어 있습니다.&#39;,
      [&#39;영상 주소 입력란에 값이 없습니다.&#39;],
      [&#39;수집할 YouTube 영상의 URL을 입력합니다.&#39;],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 입력&#39;
      }
    ));
  }

  if (!Number.isInteger(limit)) {
    throwStructuredError_(buildErrorInfo_(
      &#39;COMMENT_LIMIT_INVALID&#39;,
      &#39;수집 개수를 정수로 입력해야 합니다.&#39;,
      &#39;수집할 최상위 댓글 개수가 올바른 정수 형식이 아닙니다.&#39;,
      [&#39;빈 값, 소수 또는 문자가 입력되었을 수 있습니다.&#39;],
      [&#39;1 이상의 정수를 입력합니다.&#39;],
      {
        focusTarget: &#39;limit&#39;,
        actionLabel: &#39;수집 개수 입력&#39;
      }
    ));
  }

  if (limit &lt; YT_CFG.MIN_LIMIT || limit &gt; YT_CFG.MAX_LIMIT) {
    throwStructuredError_(buildErrorInfo_(
      &#39;COMMENT_LIMIT_OUT_OF_RANGE&#39;,
      &#39;수집 개수가 허용 범위를 벗어났습니다.&#39;,
      &#39;수집 개수는 &#39; + YT_CFG.MIN_LIMIT + &#39;개 이상 &#39; +
        YT_CFG.MAX_LIMIT + &#39;개 이하로 입력해야 합니다.&#39;,
      [&#39;입력한 개수가 코드에 설정된 운영 범위를 벗어났습니다.&#39;],
      [
        YT_CFG.MIN_LIMIT + &#39;~&#39; + YT_CFG.MAX_LIMIT +
          &#39; 사이의 정수를 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;limit&#39;,
        actionLabel: &#39;수집 개수 수정&#39;
      }
    ));
  }

  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 ... 순서로 만듭니다.
 *
 * 새 스프레드시트에 기본 빈 &#39;시트1&#39; 하나만 존재하는 경우에는 그 시트를
 * 첫 결과 시트로 사용합니다. 그 이후 수집부터는 항상 새 시트를 추가합니다.
 */
function createNextOutputSheet_(ss) {
  const sheets = ss.getSheets();

  if (
    sheets.length === 1 &amp;&amp;
    sheets[0].getName() === YT_CFG.OUTPUT_SHEET_PREFIX + &#39;1&#39; &amp;&amp;
    isSheetCompletelyBlank_(sheets[0])
  ) {
    prepareOutputSheet_(sheets[0]);
    return sheets[0];
  }

  let maxNumber = 0;
  const namePattern = new RegExp(
    &#39;^&#39; + escapeRegExp_(YT_CFG.OUTPUT_SHEET_PREFIX) + &#39;(\\d+)$&#39;
  );

  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 &amp;&amp; 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(&#39;A:A&#39;).setNumberFormat(&#39;@&#39;);
  formatCommentsSheet_(sheet);
}


/**
 * 새 수집을 위해 숨김 내부 시트만 초기화합니다.
 * 이전 결과 시트의 내용은 변경하지 않습니다.
 */
function resetInternalData_(ss) {
  const internal = ss.getSheetByName(YT_CFG.INTERNAL_SHEET);

  if (!internal) {
    throwStructuredError_(buildErrorInfo_(
      &#39;INTERNAL_SHEET_NOT_FOUND&#39;,
      &#39;내부 관리 시트를 준비하지 못했습니다.&#39;,
      &#39;댓글 수집 상태를 저장하는 숨김 시트가 존재하지 않습니다.&#39;,
      [&#39;내부 시트 생성 과정이 완료되지 않았습니다.&#39;],
      [&#39;사이드바를 닫았다가 다시 열고 새 수집을 시작합니다.&#39;],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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 &gt; 0) {
    outputSheet = ss.getSheets().find(function(sheet) {
      return sheet.getSheetId() === expectedId;
    }) || null;
  }

  if (!outputSheet &amp;&amp; state.outputSheetName) {
    outputSheet = ss.getSheetByName(String(state.outputSheetName));
  }

  if (!outputSheet) {
    throwStructuredError_(buildErrorInfo_(
      &#39;OUTPUT_SHEET_NOT_FOUND&#39;,
      &#39;수집 결과 시트를 찾을 수 없습니다.&#39;,
      &#39;현재 수집 작업과 연결된 결과 시트가 삭제되었거나 이름과 식별 정보가 변경되었습니다.&#39;,
      [
        &#39;수집 중 결과 시트를 삭제했을 수 있습니다.&#39;,
        &#39;스프레드시트 구조가 외부 작업으로 변경되었을 수 있습니다.&#39;
      ],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;사이드바에서 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;새 수집 필요&#39; }
    ));
  }

  return outputSheet;
}


function escapeRegExp_(text) {
  return String(text).replace(/[.*+?^${}()|[\]\\]/g, &#39;\\$&amp;&#39;);
}


/**
 * 이전 버전이 만든 수집로그 시트를 제거합니다.
 */
function deleteLegacyLogSheet_(ss) {
  const logSheet = ss.getSheetByName(YT_CFG.LEGACY_LOG_SHEET);

  if (logSheet &amp;&amp; ss.getSheets().length &gt; 1) {
    ss.deleteSheet(logSheet);
  }
}


/**
 * 댓글 시트를 번호 순으로 정렬하고 최종 서식을 적용합니다.
 */
function finalizeCommentsSheet_(sheet) {
  sortCommentsByDisplayNumber_(sheet);
  formatCommentsSheet_(sheet);
}


function sortCommentsByDisplayNumber_(sheet) {
  const lastRow = sheet.getLastRow();

  if (lastRow &lt;= 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] || &#39;&#39;).split(&#39;_&#39;);
    return [
      Number(parts[0] || 0),
      parts.length &gt; 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(&#39;bold&#39;)
    .setWrap(true)
    .setVerticalAlignment(&#39;middle&#39;);

  sheet.getRange(&#39;A:A&#39;).setNumberFormat(&#39;@&#39;);

  if (lastRow &gt; 1) {
    sheet
      .getRange(2, 7, lastRow - 1, 1)
      .setNumberFormat(&#39;yyyy-mm-dd hh:mm:ss&#39;);

    sheet
      .getRange(2, 5, lastRow - 1, 1)
      .setWrap(true)
      .setVerticalAlignment(&#39;top&#39;);

    sheet
      .getRange(2, 1, lastRow - 1, YT_CFG.HEADERS.length)
      .setVerticalAlignment(&#39;top&#39;);

    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 &lt;= 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 &lt;= 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] === &#39;topLevel&#39;) {
      counts.top += 1;
    } else if (row[0] === &#39;reply&#39;) {
      counts.reply += 1;
    }
  });

  return counts;
}


/**
 * 댓글 글자수를 지정된 바이트 규칙으로 계산합니다.
 * - ASCII 영역(영문, 숫자, 공백, 일반 문장부호): 1바이트
 * - 한글을 포함한 비ASCII 유니코드 문자: 코드 포인트당 2바이트
 */
function calculateCommentBytes_(value) {
  const text = String(value || &#39;&#39;);
  let bytes = 0;

  Array.from(text).forEach(function(character) {
    const codePoint = character.codePointAt(0);
    bytes += codePoint &lt;= 0x7F ? 1 : 2;
  });

  return bytes;
}


/**
 * 외부 문자열이 시트 수식으로 실행되는 것을 방지합니다.
 */
function safeCellText_(value) {
  const text = value === undefined || value === null ? &#39;&#39; : String(value);
  return /^[=+\-@]/.test(text) ? &quot;&#39;&quot; + text : text;
}


function apiDateToSheetValue_(value) {
  return value ? new Date(value) : &#39;&#39;;
}


function ensureColumnCount_(sheet, requiredColumns) {
  const current = sheet.getMaxColumns();

  if (current &lt; requiredColumns) {
    sheet.insertColumnsAfter(current, requiredColumns - current);
  }
}


function ensureExactColumnCount_(sheet, requiredColumns) {
  ensureColumnCount_(sheet, requiredColumns);

  const current = sheet.getMaxColumns();

  if (current &gt; 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_(
      &#39;API_KEY_REQUIRED&#39;,
      &#39;API 키 설정이 필요합니다.&#39;,
      &#39;댓글 수집을 시작하기 전에 YouTube Data API 키를 저장해야 합니다.&#39;,
      [&#39;현재 사용자 계정에 저장된 API 키가 없습니다.&#39;],
      [
        &#39;사이드바 상단의 API 키 입력란에 키를 입력합니다.&#39;,
        &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
      ],
      {
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 입력&#39;
      }
    ));
  }

  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 &gt; 8500) {
    throwStructuredError_(buildErrorInfo_(
      &#39;STATE_STORAGE_LIMIT&#39;,
      &#39;수집 진행 상태를 저장할 수 없습니다.&#39;,
      &#39;Apps Script 사용자 속성의 안전 저장 크기를 초과했습니다.&#39;,
      [&#39;진행 상태 데이터가 예상보다 커졌습니다.&#39;],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;댓글 수집 개수를 줄여 새로 실행합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, 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 || &#39;&#39;,
    videoId: state.videoId || &#39;&#39;,
    videoTitle: state.videoTitle || &#39;&#39;,
    targetTopLevelCount: Number(state.targetTopLevelCount || 0),
    topCount: Number(state.topCount || 0),
    replyCount: Number(state.replyCount || 0),
    apiCalls: Number(state.apiCalls || 0),
    startedAt: state.startedAt || &#39;&#39;,
    updatedAt: new Date().toISOString(),
    finishedAt: new Date().toISOString(),
    message: message || &#39;&#39;,
    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_(
      &#39;STATE_VERSION_MISMATCH&#39;,
      &#39;이전 코드에서 시작된 수집 작업을 이어갈 수 없습니다.&#39;,
      &#39;현재 Code.gs의 댓글 저장 구조가 이전 실행 상태와 다릅니다.&#39;,
      [&#39;고정 결과 시트 또는 정렬 기준을 사용하던 이전 코드의 작업 상태가 남아 있습니다.&#39;],
      [
        &#39;사이드바에서 진행 중인 수집을 취소합니다.&#39;,
        &#39;현재 코드로 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;새 수집 필요&#39; }
    ));
  }
}


function normalizeState_(state) {
  state.currentReplyParentId = state.currentReplyParentId || &#39;&#39;;
  state.nextReplyPageToken = state.nextReplyPageToken || &#39;&#39;;
  state.nextThreadPageToken = state.nextThreadPageToken || &#39;&#39;;
  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 || &#39;&#39;);
  state.error = state.error || null;
}


function maskApiKey_(apiKey) {
  const key = String(apiKey || &#39;&#39;);
  return &#39;••••••••••••&#39; + 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 &gt; 0
    ? Math.min(100, Math.round((top / target) * 100))
    : 0;

  let stage = &#39;대기 중&#39;;

  if (status === &#39;RUNNING&#39;) {
    if (state.currentReplyParentId) {
      stage = &#39;답글 수집 중&#39;;
    } else if (!state.threadDone) {
      stage = &#39;최상위 댓글 수집 중&#39;;
    } else {
      stage = &#39;마무리 중&#39;;
    }
  } else if (status === &#39;COMPLETED&#39;) {
    stage = &#39;완료&#39;;
  } else if (status === &#39;ERROR&#39;) {
    stage = &#39;오류&#39;;
  } else if (status === &#39;CANCELLED&#39;) {
    stage = &#39;취소됨&#39;;
  }

  return {
    status: status,
    stage: stage,
    outputSheetId: Number(state.outputSheetId || 0),
    outputSheetName: state.outputSheetName || &#39;&#39;,
    videoId: state.videoId || &#39;&#39;,
    videoTitle: state.videoTitle || &#39;&#39;,
    targetTopLevelCount: target,
    topCount: top,
    replyCount: reply,
    totalRows: top + reply,
    apiCalls: Number(state.apiCalls || 0),
    percent: percent,
    startedAt: state.startedAt || &#39;&#39;,
    updatedAt: state.updatedAt || &#39;&#39;,
    message: state.message || &#39;&#39;,
    error: state.error || null
  };
}


function buildServerProgressMessage_(state) {
  return (
    &#39;최상위 댓글 &#39; +
    state.topCount +
    &#39;/&#39; +
    state.targetTopLevelCount +
    &#39;개, 답글 &#39; +
    state.replyCount +
    &#39;개를 저장했습니다.&#39;
  );
}


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 || &#39;UNKNOWN_ERROR&#39;),
    title: String(title || &#39;작업을 완료하지 못했습니다.&#39;),
    summary: String(summary || &#39;처리 중 문제가 발생했습니다.&#39;),
    causes: Array.isArray(causes) ? causes.map(String) : [],
    steps: Array.isArray(steps) ? steps.map(String) : [],
    helpLabel: String(opts.helpLabel || &#39;&#39;),
    helpUrl: String(opts.helpUrl || &#39;&#39;),
    focusTarget: String(opts.focusTarget || &#39;&#39;),
    actionLabel: String(opts.actionLabel || &#39;확인&#39;),
    technical:
      opts.technical &amp;&amp; typeof opts.technical === &#39;object&#39;
        ? 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 || &#39;&#39;);
  const markerIndex = raw.indexOf(YT_CFG.STRUCTURED_ERROR_PREFIX);

  if (markerIndex &lt; 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 &amp;&amp; error.message
    ? String(error.message)
    : String(error || &#39;&#39;);

  const structured = extractStructuredError_(raw);

  if (structured) {
    return structured;
  }

  const lower = raw.toLowerCase();
  const technical = {
    context: String(context || &#39;작업 실행&#39;),
    originalMessage: raw
  };

  if (
    lower.indexOf(&#39;authorization is required&#39;) &gt;= 0 ||
    lower.indexOf(&#39;you do not have permission&#39;) &gt;= 0 ||
    lower.indexOf(&#39;permissions&#39;) &gt;= 0 &amp;&amp; lower.indexOf(&#39;required&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;,
      &#39;Google 권한 승인이 필요합니다.&#39;,
      &#39;현재 계정이 스프레드시트 또는 외부 API 요청 권한을 아직 승인하지 않았습니다.&#39;,
      [&#39;Apps Script를 처음 실행했거나 새로운 권한이 추가되었습니다.&#39;],
      [
        &#39;스프레드시트 메뉴에서 사이드바를 다시 엽니다.&#39;,
        &#39;권한 검토 화면에서 현재 Google 계정을 선택합니다.&#39;,
        &#39;요청되는 권한을 허용한 뒤 다시 실행합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (
    lower.indexOf(&#39;maximum execution time&#39;) &gt;= 0 ||
    lower.indexOf(&#39;exceeded maximum execution time&#39;) &gt;= 0 ||
    lower.indexOf(&#39;timed out&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;,
      &#39;한 번의 실행 가능 시간을 초과했습니다.&#39;,
      &#39;댓글 또는 답글 처리 시간이 길어 현재 실행 구간이 종료되었습니다.&#39;,
      [&#39;대량 댓글 수집으로 한 번의 Apps Script 실행 시간이 길어졌습니다.&#39;],
      [
        &#39;사이드바를 다시 열어 진행 상태를 확인합니다.&#39;,
        &#39;멈춘 경우 ‘중단 지점부터 실행’을 한 번 실행합니다.&#39;,
        &#39;같은 문제가 반복되면 수집 개수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (
    lower.indexOf(&#39;service invoked too many times&#39;) &gt;= 0 ||
    lower.indexOf(&#39;quota&#39;) &gt;= 0 &amp;&amp; lower.indexOf(&#39;apps script&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_SERVICE_QUOTA&#39;,
      &#39;Apps Script 서비스 사용 한도를 초과했습니다.&#39;,
      &#39;현재 Google 계정 또는 스크립트의 일일 서비스 사용량이 허용 범위를 넘었습니다.&#39;,
      [&#39;짧은 시간에 많은 외부 요청이나 스프레드시트 쓰기 작업이 실행되었습니다.&#39;],
      [
        &#39;잠시 뒤 다시 실행합니다.&#39;,
        &#39;같은 날 반복되면 다음 날 다시 실행합니다.&#39;,
        &#39;한 번에 수집하는 댓글 개수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  return buildErrorInfo_(
    &#39;UNEXPECTED_ERROR&#39;,
    &#39;예상하지 못한 오류가 발생했습니다.&#39;,
    &#39;오류 유형을 자동으로 판별하지 못했습니다.&#39;,
    [
      &#39;네트워크, 권한, Google 서비스 상태 또는 실행 환경에서 문제가 발생했을 수 있습니다.&#39;
    ],
    [
      &#39;사이드바를 닫았다가 다시 열고 같은 작업을 한 번 더 실행합니다.&#39;,
      &#39;Apps Script 편집기의 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;,
      &#39;API 키와 영상 주소를 다시 확인합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: technical }
  );
}
</pre></section><section><h3>Sidebar.html</h3><pre>&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;meta charset=&quot;utf-8&quot;&gt;
    &lt;style&gt;
      :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, &#39;Noto Sans KR&#39;, 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=&#39;text&#39;],
      input[type=&#39;password&#39;],
      input[type=&#39;number&#39;] {
        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);
        }
      }
    &lt;/style&gt;
  &lt;/head&gt;

  &lt;body&gt;
    &lt;h1&gt;YouTube 댓글 수집기&lt;/h1&gt;
    &lt;p class=&quot;subtitle&quot;&gt;YouTube Data API v3 → Google Sheets&lt;/p&gt;

    &lt;div id=&quot;message&quot; class=&quot;message&quot;&gt;&lt;/div&gt;

    &lt;section class=&quot;card&quot;&gt;
      &lt;h2&gt;1. API 키&lt;/h2&gt;

      &lt;div id=&quot;apiMissingView&quot;&gt;
        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;apiKey&quot;&gt;YouTube Data API 키&lt;/label&gt;
          &lt;input
            id=&quot;apiKey&quot;
            type=&quot;password&quot;
            autocomplete=&quot;off&quot;
            placeholder=&quot;AIza...&quot;
          &gt;
          &lt;div class=&quot;help&quot;&gt;
            최초 1회 저장합니다. 키는 사용자별 Apps Script 속성에 보관됩니다.
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;button id=&quot;saveApiButton&quot; class=&quot;primary&quot; type=&quot;button&quot;&gt;
          API 키 확인 후 저장
        &lt;/button&gt;
      &lt;/div&gt;

      &lt;div id=&quot;apiSavedView&quot; class=&quot;hidden&quot;&gt;
        &lt;div class=&quot;row between&quot;&gt;
          &lt;div class=&quot;status-line&quot;&gt;
            &lt;span class=&quot;badge success&quot;&gt;설정 완료&lt;/span&gt;
            &lt;span id=&quot;apiKeyMask&quot; class=&quot;masked-key&quot;&gt;&lt;/span&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;div class=&quot;row&quot; style=&quot;margin-top: 10px;&quot;&gt;
          &lt;button id=&quot;changeApiButton&quot; class=&quot;secondary small-button&quot; type=&quot;button&quot;&gt;
            키 변경
          &lt;/button&gt;
          &lt;button id=&quot;deleteApiButton&quot; class=&quot;danger small-button&quot; type=&quot;button&quot;&gt;
            키 삭제
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/section&gt;

    &lt;section class=&quot;card soft&quot;&gt;
      &lt;h2&gt;2. 수집 설정&lt;/h2&gt;

      &lt;div id=&quot;lockedMessage&quot; class=&quot;locked-message&quot;&gt;
        API 키를 저장하면 수집 설정이 활성화됩니다.
      &lt;/div&gt;

      &lt;fieldset id=&quot;collectorFieldset&quot; disabled&gt;
        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;videoUrl&quot;&gt;YouTube 영상 주소&lt;/label&gt;
          &lt;input
            id=&quot;videoUrl&quot;
            type=&quot;text&quot;
            placeholder=&quot;https://www.youtube.com/watch?v=...&quot;
          &gt;
        &lt;/div&gt;

        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;limit&quot;&gt;수집할 최상위 댓글 수&lt;/label&gt;
          &lt;input
            id=&quot;limit&quot;
            type=&quot;number&quot;
            min=&quot;1&quot;
            max=&quot;50000&quot;
            step=&quot;1&quot;
            value=&quot;100&quot;
          &gt;
          &lt;div class=&quot;help&quot;&gt;
            1~50,000개. 답글 포함 시 실제 저장 행 수가 이 값보다 많아질 수 있습니다.
          &lt;/div&gt;
        &lt;/div&gt;


        &lt;div class=&quot;field check-row&quot;&gt;
          &lt;input id=&quot;includeReplies&quot; type=&quot;checkbox&quot; checked&gt;
          &lt;label for=&quot;includeReplies&quot;&gt;
            대댓글도 모두 수집
          &lt;/label&gt;
        &lt;/div&gt;

        &lt;div class=&quot;help&quot; style=&quot;margin-bottom: 10px;&quot;&gt;
          새 수집을 시작할 때마다 ‘시트1’, ‘시트2’, ‘시트3’ 형식의 새 시트가 생성됩니다. 기존 수집 결과는 유지됩니다.
        &lt;/div&gt;

        &lt;button id=&quot;startButton&quot; class=&quot;primary&quot; type=&quot;button&quot;&gt;
          수집 시작
        &lt;/button&gt;
      &lt;/fieldset&gt;
    &lt;/section&gt;

    &lt;section id=&quot;progressCard&quot; class=&quot;card hidden&quot;&gt;
      &lt;div class=&quot;row between&quot;&gt;
        &lt;h2 style=&quot;margin: 0;&quot;&gt;진행 상태&lt;/h2&gt;
        &lt;span id=&quot;progressBadge&quot; class=&quot;badge warning&quot;&gt;대기 중&lt;/span&gt;
      &lt;/div&gt;

      &lt;div id=&quot;progressTitle&quot; class=&quot;progress-title&quot; style=&quot;margin-top: 10px;&quot;&gt;&lt;/div&gt;

      &lt;div class=&quot;progress-bar&quot;&gt;
        &lt;div id=&quot;progressFill&quot; class=&quot;progress-fill&quot;&gt;&lt;/div&gt;
      &lt;/div&gt;

      &lt;div class=&quot;metric-grid&quot;&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;댓글&lt;/div&gt;
          &lt;div id=&quot;topMetric&quot; class=&quot;metric-value&quot;&gt;0 / 0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;대댓글&lt;/div&gt;
          &lt;div id=&quot;replyMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;전체 저장 행&lt;/div&gt;
          &lt;div id=&quot;totalMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;API 호출 횟수&lt;/div&gt;
          &lt;div id=&quot;apiCallMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;p id=&quot;progressMessage&quot; class=&quot;help&quot;&gt;&lt;/p&gt;

      &lt;button id=&quot;cancelButton&quot; class=&quot;danger hidden&quot; type=&quot;button&quot; style=&quot;width: 100%; margin-top: 12px;&quot;&gt;
        진행 중인 수집 취소
      &lt;/button&gt;
    &lt;/section&gt;


    &lt;div
      id=&quot;errorModalBackdrop&quot;
      class=&quot;modal-backdrop hidden&quot;
      role=&quot;dialog&quot;
      aria-modal=&quot;true&quot;
      aria-labelledby=&quot;errorModalTitle&quot;
    &gt;
      &lt;div class=&quot;modal-panel&quot;&gt;
        &lt;div class=&quot;modal-header&quot;&gt;
          &lt;div class=&quot;modal-icon&quot; aria-hidden=&quot;true&quot;&gt;!&lt;/div&gt;

          &lt;div class=&quot;modal-heading&quot;&gt;
            &lt;h2 id=&quot;errorModalTitle&quot; class=&quot;modal-title&quot;&gt;
              문제를 확인해야 합니다.
            &lt;/h2&gt;
            &lt;div id=&quot;errorModalCode&quot; class=&quot;modal-code&quot;&gt;&lt;/div&gt;
          &lt;/div&gt;

          &lt;button
            id=&quot;errorModalCloseButton&quot;
            class=&quot;modal-close&quot;
            type=&quot;button&quot;
            aria-label=&quot;오류 안내 닫기&quot;
          &gt;
            ×
          &lt;/button&gt;
        &lt;/div&gt;

        &lt;div class=&quot;modal-body&quot;&gt;
          &lt;div id=&quot;errorModalSummary&quot; class=&quot;modal-summary&quot;&gt;&lt;/div&gt;

          &lt;section id=&quot;errorCauseSection&quot; class=&quot;modal-section&quot;&gt;
            &lt;h3&gt;발생 가능한 원인&lt;/h3&gt;
            &lt;ul id=&quot;errorCauseList&quot;&gt;&lt;/ul&gt;
          &lt;/section&gt;

          &lt;section id=&quot;errorStepSection&quot; class=&quot;modal-section&quot;&gt;
            &lt;h3&gt;해결 방법&lt;/h3&gt;
            &lt;ol id=&quot;errorStepList&quot;&gt;&lt;/ol&gt;
          &lt;/section&gt;

          &lt;details id=&quot;errorTechnicalDetails&quot; class=&quot;technical-details&quot;&gt;
            &lt;summary&gt;기술 정보 보기&lt;/summary&gt;
            &lt;div id=&quot;errorTechnicalGrid&quot; class=&quot;technical-grid&quot;&gt;&lt;/div&gt;
          &lt;/details&gt;
        &lt;/div&gt;

        &lt;div class=&quot;modal-actions&quot;&gt;
          &lt;a
            id=&quot;errorHelpLink&quot;
            class=&quot;modal-help-link hidden&quot;
            href=&quot;#&quot;
            target=&quot;_blank&quot;
            rel=&quot;noopener noreferrer&quot;
          &gt;&lt;/a&gt;

          &lt;button
            id=&quot;errorModalPrimaryButton&quot;
            class=&quot;primary&quot;
            type=&quot;button&quot;
          &gt;
            확인
          &lt;/button&gt;

          &lt;button
            id=&quot;errorModalDismissButton&quot;
            class=&quot;secondary&quot;
            type=&quot;button&quot;
          &gt;
            닫기
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;script&gt;
      const ERROR_PREFIX = &#39;YT_STRUCTURED_ERROR::&#39;;

      let currentState = null;
      let pollTimer = null;
      let pollInFlight = false;
      let lastShownErrorKey = &#39;&#39;;
      let pendingFocusTarget = &#39;&#39;;

      document.addEventListener(&#39;DOMContentLoaded&#39;, function() {
        bindEvents();
        refreshState(false);
      });

      function bindEvents() {
        document.getElementById(&#39;saveApiButton&#39;).addEventListener(&#39;click&#39;, saveApiKey);
        document.getElementById(&#39;changeApiButton&#39;).addEventListener(&#39;click&#39;, showApiKeyEditor);
        document.getElementById(&#39;deleteApiButton&#39;).addEventListener(&#39;click&#39;, deleteApiKey);
        document.getElementById(&#39;startButton&#39;).addEventListener(&#39;click&#39;, startCollection);
        document.getElementById(&#39;cancelButton&#39;).addEventListener(&#39;click&#39;, cancelCollection);

        document
          .getElementById(&#39;errorModalCloseButton&#39;)
          .addEventListener(&#39;click&#39;, closeErrorModal);

        document
          .getElementById(&#39;errorModalDismissButton&#39;)
          .addEventListener(&#39;click&#39;, closeErrorModal);

        document
          .getElementById(&#39;errorModalPrimaryButton&#39;)
          .addEventListener(&#39;click&#39;, handleErrorModalPrimaryAction);

        document
          .getElementById(&#39;errorModalBackdrop&#39;)
          .addEventListener(&#39;click&#39;, function(event) {
            if (event.target === event.currentTarget) {
              closeErrorModal();
            }
          });

        document.addEventListener(&#39;keydown&#39;, function(event) {
          if (event.key === &#39;Escape&#39;) {
            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, &#39;상태 확인&#39;);
            }
          })
          .getSidebarState();
      }

      function renderState(state) {
        currentState = state || {};

        const configured = Boolean(currentState.apiKeyConfigured);
        const running = Boolean(currentState.running);

        document.getElementById(&#39;apiMissingView&#39;).classList.toggle(&#39;hidden&#39;, configured);
        document.getElementById(&#39;apiSavedView&#39;).classList.toggle(&#39;hidden&#39;, !configured);
        document.getElementById(&#39;apiKeyMask&#39;).textContent = currentState.apiKeyMask || &#39;&#39;;

        document.getElementById(&#39;collectorFieldset&#39;).disabled = !configured || running;
        document.getElementById(&#39;lockedMessage&#39;).classList.toggle(&#39;hidden&#39;, configured);

        document.getElementById(&#39;changeApiButton&#39;).disabled = running;
        document.getElementById(&#39;deleteApiButton&#39;).disabled = running;

        renderProgress(currentState.progress, running);

        if (running) {
          startPolling();
        } else {
          stopPolling();
        }
      }

      function renderProgress(progress, running) {
        const card = document.getElementById(&#39;progressCard&#39;);

        if (!progress) {
          card.classList.add(&#39;hidden&#39;);
          return;
        }

        card.classList.remove(&#39;hidden&#39;);

        const status = progress.status || &#39;&#39;;
        const badge = document.getElementById(&#39;progressBadge&#39;);
        badge.textContent = progress.stage || status;
        badge.className = &#39;badge &#39; + getBadgeClass(status);

        const progressTitle =
          progress.videoTitle || progress.videoId || &#39;YouTube 영상&#39;;
        const outputSheetName = progress.outputSheetName || &#39;&#39;;

        document.getElementById(&#39;progressTitle&#39;).textContent =
          outputSheetName
            ? progressTitle + &#39; · &#39; + outputSheetName
            : progressTitle;

        document.getElementById(&#39;progressFill&#39;).style.width =
          String(progress.percent || 0) + &#39;%&#39;;

        document.getElementById(&#39;topMetric&#39;).textContent =
          formatNumber(progress.topCount) + &#39; / &#39; + formatNumber(progress.targetTopLevelCount);

        document.getElementById(&#39;replyMetric&#39;).textContent = formatNumber(progress.replyCount);
        document.getElementById(&#39;totalMetric&#39;).textContent = formatNumber(progress.totalRows);
        document.getElementById(&#39;apiCallMetric&#39;).textContent = formatNumber(progress.apiCalls);
        document.getElementById(&#39;progressMessage&#39;).textContent = progress.message || &#39;&#39;;
        document.getElementById(&#39;cancelButton&#39;).classList.toggle(&#39;hidden&#39;, !running);

        if (status === &#39;ERROR&#39;) {
          const errorInfo = progress.error || buildFallbackErrorInfo(
            progress.message || &#39;&#39;,
            &#39;댓글 수집&#39;
          );

          const errorKey = [
            errorInfo.code || &#39;UNKNOWN_ERROR&#39;,
            progress.updatedAt || &#39;&#39;,
            progress.videoId || &#39;&#39;
          ].join(&#39;|&#39;);

          if (errorKey !== lastShownErrorKey) {
            lastShownErrorKey = errorKey;
            showErrorModal(errorInfo);
          }
        }
      }

      function saveApiKey() {
        const input = document.getElementById(&#39;apiKey&#39;);
        const button = document.getElementById(&#39;saveApiButton&#39;);
        const apiKey = input.value.trim();

        if (!apiKey) {
          showErrorModal({
            code: &#39;API_KEY_REQUIRED&#39;,
            title: &#39;API 키를 입력해야 합니다.&#39;,
            summary: &#39;API 키 입력란이 비어 있어 검증을 시작할 수 없습니다.&#39;,
            causes: [
              &#39;입력란에 값이 입력되지 않았습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’에서 API 키를 확인합니다.&#39;,
              &#39;키 값을 복사하여 입력란에 붙여 넣습니다.&#39;,
              &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
            ],
            helpLabel: &#39;API 키 관리 화면 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;API 키 입력&#39;
          });
          return;
        }

        setButtonBusy(button, true, &#39;확인 중&#39;);
        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            input.value = &#39;&#39;;
            setButtonBusy(button, false, &#39;API 키 확인 후 저장&#39;);
            renderState(state);
            showMessage(&#39;API 키를 검증하여 저장했습니다.&#39;, &#39;success&#39;);
          })
          .withFailureHandler(function(error) {
            setButtonBusy(button, false, &#39;API 키 확인 후 저장&#39;);
            handleServerError(error, &#39;API 키 검증&#39;);
          })
          .saveApiKey(apiKey);
      }

      function showApiKeyEditor() {
        document.getElementById(&#39;apiSavedView&#39;).classList.add(&#39;hidden&#39;);
        document.getElementById(&#39;apiMissingView&#39;).classList.remove(&#39;hidden&#39;);
        document.getElementById(&#39;apiKey&#39;).focus();
        showMessage(&#39;새 API 키를 입력하면 기존 키가 교체됩니다.&#39;, &#39;info&#39;);
      }

      function deleteApiKey() {
        if (!window.confirm(&#39;저장된 API 키를 삭제하시겠습니까?&#39;)) {
          return;
        }

        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            renderState(state);
            showMessage(&#39;저장된 API 키를 삭제했습니다.&#39;, &#39;success&#39;);
          })
          .withFailureHandler(function(error) {
            handleServerError(error, &#39;API 키 삭제&#39;);
          })
          .deleteApiKey();
      }

      function startCollection() {
        const videoUrl = document.getElementById(&#39;videoUrl&#39;).value.trim();
        const limitText = document.getElementById(&#39;limit&#39;).value;
        const limit = Number(limitText);
        const startButton = document.getElementById(&#39;startButton&#39;);

        if (!videoUrl) {
          showErrorModal({
            code: &#39;VIDEO_URL_REQUIRED&#39;,
            title: &#39;영상 주소를 입력해야 합니다.&#39;,
            summary: &#39;수집 대상 YouTube 영상 주소가 비어 있습니다.&#39;,
            causes: [
              &#39;영상 주소 입력란에 값이 없습니다.&#39;
            ],
            steps: [
              &#39;YouTube에서 수집할 영상을 엽니다.&#39;,
              &#39;주소창의 전체 영상 주소를 복사하여 입력합니다.&#39;
            ],
            focusTarget: &#39;videoUrl&#39;,
            actionLabel: &#39;영상 주소 입력&#39;
          });
          return;
        }

        if (!Number.isInteger(limit) || limit &lt; 1 || limit &gt; 50000) {
          showErrorModal({
            code: &#39;COMMENT_LIMIT_INVALID&#39;,
            title: &#39;수집 개수를 확인해야 합니다.&#39;,
            summary: &#39;수집 개수는 1~50,000 사이의 정수로 입력해야 합니다.&#39;,
            causes: [
              &#39;빈 값, 소수, 문자 또는 허용 범위를 벗어난 수가 입력되었습니다.&#39;
            ],
            steps: [
              &#39;수집 개수 입력란에 1~50,000 사이의 정수를 입력합니다.&#39;,
              &#39;예: 100, 500, 1000&#39;
            ],
            focusTarget: &#39;limit&#39;,
            actionLabel: &#39;수집 개수 수정&#39;
          });
          return;
        }

        const payload = {
          videoUrl: videoUrl,
          limit: limit,
          includeReplies: document.getElementById(&#39;includeReplies&#39;).checked
        };

        setButtonBusy(startButton, true, &#39;준비 중&#39;);
        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            setButtonBusy(startButton, false, &#39;수집 시작&#39;);
            renderState(state);
            const sheetName =
              state &amp;&amp;
              state.progress &amp;&amp;
              state.progress.outputSheetName
                ? state.progress.outputSheetName
                : &#39;&#39;;

            showMessage(
              sheetName
                ? sheetName + &#39;에서 수집 작업을 시작했습니다.&#39;
                : &#39;수집 작업을 시작했습니다.&#39;,
              &#39;info&#39;
            );
            startPolling();

            // 서버에서 첫 수집 구간을 즉시 실행합니다.
            google.script.run
              .withSuccessHandler(function(updatedState) {
                renderState(updatedState);
              })
              .withFailureHandler(function(error) {
                refreshState(true);
                handleServerError(error, &#39;댓글 수집&#39;);
              })
              .runCollectionNow();
          })
          .withFailureHandler(function(error) {
            setButtonBusy(startButton, false, &#39;수집 시작&#39;);
            handleServerError(error, &#39;수집 준비&#39;);
          })
          .startCollectionFromSidebar(payload);
      }

      function cancelCollection() {
        if (!window.confirm(&#39;현재 수집 작업을 취소하시겠습니까? 이미 저장된 행은 유지됩니다.&#39;)) {
          return;
        }

        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            renderState(state);

            if (state &amp;&amp; state.running) {
              showMessage(&#39;취소 요청을 보냈습니다. 현재 API 페이지 처리가 끝나면 안전하게 중단됩니다.&#39;, &#39;info&#39;);
              startPolling();
            } else {
              showMessage(&#39;수집 작업을 취소했습니다.&#39;, &#39;success&#39;);
            }
          })
          .withFailureHandler(function(error) {
            handleServerError(error, &#39;수집 취소&#39;);
          })
          .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 = &#39;&lt;span class=&quot;row&quot; style=&quot;justify-content:center;&quot;&gt;&lt;span class=&quot;spinner&quot;&gt;&lt;/span&gt;&lt;span&gt;&#39; +
            escapeHtml(label) +
            &#39;&lt;/span&gt;&lt;/span&gt;&#39;;
        } else {
          button.textContent = label;
        }
      }

      function showMessage(text, type) {
        const box = document.getElementById(&#39;message&#39;);
        box.textContent = text || &#39;&#39;;
        box.className = &#39;message show &#39; + (type || &#39;info&#39;);
      }

      function clearMessage() {
        const box = document.getElementById(&#39;message&#39;);
        box.textContent = &#39;&#39;;
        box.className = &#39;message&#39;;
      }

      function getBadgeClass(status) {
        if (status === &#39;COMPLETED&#39;) {
          return &#39;success&#39;;
        }

        if (status === &#39;ERROR&#39; || status === &#39;CANCELLED&#39;) {
          return &#39;danger&#39;;
        }

        return &#39;warning&#39;;
      }

      function handleServerError(error, context) {
        const errorInfo = parseServerError(error, context);
        showErrorModal(errorInfo);
      }

      function parseServerError(error, context) {
        if (error &amp;&amp; error.title &amp;&amp; error.summary) {
          return normalizeClientErrorInfo(error);
        }

        const raw = error &amp;&amp; error.message
          ? String(error.message)
          : String(error || &#39;&#39;);

        const structured = extractStructuredError(raw);

        if (structured) {
          return normalizeClientErrorInfo(structured);
        }

        return buildFallbackErrorInfo(raw, context);
      }

      function extractStructuredError(rawText) {
        const raw = String(rawText || &#39;&#39;);
        const markerIndex = raw.indexOf(ERROR_PREFIX);

        if (markerIndex &lt; 0) {
          return null;
        }

        const objectStart = raw.indexOf(
          &#39;{&#39;,
          markerIndex + ERROR_PREFIX.length
        );

        if (objectStart &lt; 0) {
          return null;
        }

        let depth = 0;
        let inString = false;
        let escaped = false;

        for (let index = objectStart; index &lt; raw.length; index += 1) {
          const character = raw.charAt(index);

          if (inString) {
            if (escaped) {
              escaped = false;
            } else if (character === &#39;\\&#39;) {
              escaped = true;
            } else if (character === &#39;&quot;&#39;) {
              inString = false;
            }

            continue;
          }

          if (character === &#39;&quot;&#39;) {
            inString = true;
            continue;
          }

          if (character === &#39;{&#39;) {
            depth += 1;
          } else if (character === &#39;}&#39;) {
            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 || &#39;UNKNOWN_ERROR&#39;),
          title: String(info.title || &#39;작업을 완료하지 못했습니다.&#39;),
          summary: String(
            info.summary ||
            &#39;처리 중 예상하지 못한 문제가 발생했습니다.&#39;
          ),
          causes: Array.isArray(info.causes)
            ? info.causes.map(String)
            : [],
          steps: Array.isArray(info.steps)
            ? info.steps.map(String)
            : [],
          helpLabel: String(info.helpLabel || &#39;&#39;),
          helpUrl: String(info.helpUrl || &#39;&#39;),
          focusTarget: String(info.focusTarget || &#39;&#39;),
          actionLabel: String(info.actionLabel || &#39;확인&#39;),
          technical:
            info.technical &amp;&amp; typeof info.technical === &#39;object&#39;
              ? info.technical
              : {}
        };
      }

      function buildFallbackErrorInfo(rawText, context) {
        const raw = String(rawText || &#39;&#39;);
        const lower = raw.toLowerCase();
        const technical = {
          errorCode: &#39;UNCLASSIFIED_CLIENT_ERROR&#39;,
          context: String(context || &#39;작업 실행&#39;)
        };

        if (
          lower.indexOf(&#39;api key not valid&#39;) &gt;= 0 ||
          lower.indexOf(&#39;keyinvalid&#39;) &gt;= 0 ||
          lower.indexOf(&#39;api_key_invalid&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;API_KEY_INVALID&#39;,
            title: &#39;API 키를 사용할 수 없습니다.&#39;,
            summary: &#39;입력한 API 키가 정확하지 않거나 현재 사용할 수 없는 상태입니다.&#39;,
            causes: [
              &#39;키 일부가 누락되었거나 다른 값을 붙여 넣었을 수 있습니다.&#39;,
              &#39;Google Cloud에서 기존 키를 삭제하거나 재발급했을 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
              &#39;실제 API 키 값을 전체 복사합니다.&#39;,
              &#39;사이드바에서 키를 다시 입력하고 저장합니다.&#39;
            ],
            helpLabel: &#39;API 키 관리 화면 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;API 키 다시 입력&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;API_KEY_INVALID&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;access not configured&#39;) &gt;= 0 ||
          lower.indexOf(&#39;accessnotconfigured&#39;) &gt;= 0 ||
          lower.indexOf(&#39;has not been used in project&#39;) &gt;= 0 ||
          lower.indexOf(&#39;api_disabled&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;YOUTUBE_API_NOT_ENABLED&#39;,
            title: &#39;YouTube Data API v3가 활성화되어 있지 않습니다.&#39;,
            summary: &#39;API 키가 속한 Google Cloud 프로젝트에서 YouTube Data API v3 사용 설정을 켜야 합니다.&#39;,
            causes: [
              &#39;API 키를 만든 프로젝트에서 YouTube Data API v3를 아직 활성화하지 않았습니다.&#39;,
              &#39;API를 활성화한 프로젝트와 API 키를 발급한 프로젝트가 다를 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console에서 API 키를 만든 프로젝트를 선택합니다.&#39;,
              &#39;‘API 및 서비스 → 라이브러리’로 이동합니다.&#39;,
              &#39;‘YouTube Data API v3’를 검색하고 ‘사용’을 누릅니다.&#39;,
              &#39;설정 후 1~5분 뒤 API 키를 다시 저장합니다.&#39;
            ],
            helpLabel: &#39;YouTube Data API 설정 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/library/youtube.googleapis.com&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;설정 확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;YOUTUBE_API_NOT_ENABLED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;commentsdisabled&#39;) &gt;= 0 ||
          lower.indexOf(&#39;comments disabled&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;COMMENTS_DISABLED&#39;,
            title: &#39;이 영상에서는 댓글을 수집할 수 없습니다.&#39;,
            summary: &#39;영상 게시자가 댓글 기능을 사용하지 않도록 설정했습니다.&#39;,
            causes: [
              &#39;영상의 댓글 기능이 꺼져 있거나 YouTube 정책에 따라 제한되었습니다.&#39;
            ],
            steps: [
              &#39;영상 페이지에서 댓글 영역이 표시되는지 확인합니다.&#39;,
              &#39;댓글이 허용된 다른 영상 주소를 입력합니다.&#39;
            ],
            focusTarget: &#39;videoUrl&#39;,
            actionLabel: &#39;영상 주소 다시 입력&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;COMMENTS_DISABLED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;quotaexceeded&#39;) &gt;= 0 ||
          lower.indexOf(&#39;quota_exceeded&#39;) &gt;= 0 ||
          lower.indexOf(&#39;daily limit&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;YOUTUBE_QUOTA_EXCEEDED&#39;,
            title: &#39;YouTube API의 오늘 할당량을 모두 사용했습니다.&#39;,
            summary: &#39;현재 프로젝트에서 오늘 사용할 수 있는 YouTube Data API 할당량이 남아 있지 않습니다.&#39;,
            causes: [
              &#39;같은 프로젝트의 다른 작업에서도 YouTube API를 사용했을 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console에서 YouTube Data API의 오늘 사용량을 확인합니다.&#39;,
              &#39;일일 할당량이 갱신된 뒤 다시 실행합니다.&#39;,
              &#39;다음 수집에서는 요청 댓글 수를 줄입니다.&#39;
            ],
            helpLabel: &#39;YouTube API 할당량 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas&#39;,
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;YOUTUBE_QUOTA_EXCEEDED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;authorization is required&#39;) &gt;= 0 ||
          lower.indexOf(&#39;you do not have permission&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;,
            title: &#39;Google 권한 승인이 필요합니다.&#39;,
            summary: &#39;현재 계정이 스프레드시트 또는 외부 API 요청 권한을 아직 승인하지 않았습니다.&#39;,
            causes: [
              &#39;Apps Script를 처음 실행했거나 새로운 권한이 추가되었습니다.&#39;
            ],
            steps: [
              &#39;스프레드시트 메뉴에서 사이드바를 다시 엽니다.&#39;,
              &#39;권한 검토 화면에서 현재 계정을 선택합니다.&#39;,
              &#39;요청되는 권한을 허용한 뒤 다시 실행합니다.&#39;
            ],
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;timed out&#39;) &gt;= 0 ||
          lower.indexOf(&#39;maximum execution time&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;,
            title: &#39;한 번의 실행 가능 시간을 초과했습니다.&#39;,
            summary: &#39;댓글이 많아 현재 실행 구간이 종료되었습니다.&#39;,
            causes: [
              &#39;대량 댓글 또는 답글 처리 시간이 길어졌습니다.&#39;
            ],
            steps: [
              &#39;진행 상태가 자동으로 이어지는지 확인합니다.&#39;,
              &#39;멈춘 경우 ‘중단 지점부터 실행’을 한 번 실행합니다.&#39;,
              &#39;반복되면 수집 개수를 줄입니다.&#39;
            ],
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;
            })
          });
        }

        return normalizeClientErrorInfo({
          code: &#39;UNEXPECTED_ERROR&#39;,
          title: &#39;예상하지 못한 오류가 발생했습니다.&#39;,
          summary: &#39;오류 유형을 자동으로 판별하지 못했습니다. 입력값과 Apps Script 실행 기록을 확인해야 합니다.&#39;,
          causes: [
            &#39;네트워크 연결, Google 서비스 상태, 권한 또는 실행 환경에서 문제가 발생했을 수 있습니다.&#39;
          ],
          steps: [
            &#39;사이드바를 닫았다가 다시 열고 같은 작업을 한 번 더 실행합니다.&#39;,
            &#39;Apps Script 편집기의 왼쪽 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;,
            &#39;API 키 저장 상태와 영상 주소를 다시 확인합니다.&#39;
          ],
          actionLabel: &#39;확인&#39;,
          technical: technical
        });
      }

      function showErrorModal(errorInfo) {
        const info = normalizeClientErrorInfo(errorInfo);
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);
        const title = document.getElementById(&#39;errorModalTitle&#39;);
        const code = document.getElementById(&#39;errorModalCode&#39;);
        const summary = document.getElementById(&#39;errorModalSummary&#39;);
        const causeSection = document.getElementById(&#39;errorCauseSection&#39;);
        const stepSection = document.getElementById(&#39;errorStepSection&#39;);
        const causeList = document.getElementById(&#39;errorCauseList&#39;);
        const stepList = document.getElementById(&#39;errorStepList&#39;);
        const helpLink = document.getElementById(&#39;errorHelpLink&#39;);
        const primaryButton = document.getElementById(&#39;errorModalPrimaryButton&#39;);

        clearMessage();

        title.textContent = info.title;
        code.textContent = &#39;오류 코드: &#39; + info.code;
        summary.textContent = info.summary;

        renderTextList(causeList, info.causes);
        renderTextList(stepList, info.steps);

        causeSection.classList.toggle(&#39;hidden&#39;, info.causes.length === 0);
        stepSection.classList.toggle(&#39;hidden&#39;, info.steps.length === 0);

        if (
          info.helpUrl &amp;&amp;
          /^https:\/\/console\.cloud\.google\.com\//.test(info.helpUrl)
        ) {
          helpLink.href = info.helpUrl;
          helpLink.textContent = info.helpLabel || &#39;Google Cloud 설정 열기&#39;;
          helpLink.classList.remove(&#39;hidden&#39;);
        } else {
          helpLink.removeAttribute(&#39;href&#39;);
          helpLink.textContent = &#39;&#39;;
          helpLink.classList.add(&#39;hidden&#39;);
        }

        primaryButton.textContent = info.actionLabel || &#39;확인&#39;;
        pendingFocusTarget = info.focusTarget || &#39;&#39;;

        renderTechnicalInfo(info.technical);

        backdrop.classList.remove(&#39;hidden&#39;);
        document.body.style.overflow = &#39;hidden&#39;;

        window.setTimeout(function() {
          primaryButton.focus();
        }, 0);
      }

      function renderTextList(listElement, items) {
        listElement.innerHTML = &#39;&#39;;

        (items || []).forEach(function(item) {
          const listItem = document.createElement(&#39;li&#39;);
          listItem.textContent = String(item);
          listElement.appendChild(listItem);
        });
      }

      function renderTechnicalInfo(technical) {
        const details = document.getElementById(&#39;errorTechnicalDetails&#39;);
        const grid = document.getElementById(&#39;errorTechnicalGrid&#39;);
        const data = technical &amp;&amp; typeof technical === &#39;object&#39;
          ? technical
          : {};

        const labels = {
          errorCode: &#39;분류 코드&#39;,
          httpStatus: &#39;HTTP 상태&#39;,
          reason: &#39;API 사유 코드&#39;,
          resource: &#39;요청 자원&#39;,
          videoId: &#39;영상 ID&#39;,
          context: &#39;실행 단계&#39;
        };

        grid.innerHTML = &#39;&#39;;

        Object.keys(labels).forEach(function(key) {
          if (
            data[key] === undefined ||
            data[key] === null ||
            data[key] === &#39;&#39;
          ) {
            return;
          }

          const label = document.createElement(&#39;div&#39;);
          label.className = &#39;technical-label&#39;;
          label.textContent = labels[key];

          const value = document.createElement(&#39;div&#39;);
          value.className = &#39;technical-value&#39;;
          value.textContent = String(data[key]);

          grid.appendChild(label);
          grid.appendChild(value);
        });

        details.classList.toggle(&#39;hidden&#39;, grid.children.length === 0);
        details.open = false;
      }

      function closeErrorModal() {
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);

        if (backdrop.classList.contains(&#39;hidden&#39;)) {
          return;
        }

        backdrop.classList.add(&#39;hidden&#39;);
        document.body.style.overflow = &#39;&#39;;
        pendingFocusTarget = &#39;&#39;;
      }

      function handleErrorModalPrimaryAction() {
        const focusTarget = pendingFocusTarget;
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);

        backdrop.classList.add(&#39;hidden&#39;);
        document.body.style.overflow = &#39;&#39;;
        pendingFocusTarget = &#39;&#39;;

        focusErrorTarget(focusTarget);
      }

      function focusErrorTarget(targetId) {
        if (!targetId) {
          return;
        }

        if (targetId === &#39;apiKey&#39;) {
          document.getElementById(&#39;apiSavedView&#39;).classList.add(&#39;hidden&#39;);
          document.getElementById(&#39;apiMissingView&#39;).classList.remove(&#39;hidden&#39;);
        }

        const target = document.getElementById(targetId);

        if (target) {
          window.setTimeout(function() {
            target.focus();

            if (typeof target.select === &#39;function&#39;) {
              target.select();
            }
          }, 0);
        }
      }

      function formatNumber(value) {
        return Number(value || 0).toLocaleString(&#39;ko-KR&#39;);
      }

      function escapeHtml(value) {
        return String(value || &#39;&#39;)
          .replace(/&amp;/g, &#39;&amp;amp;&#39;)
          .replace(/&lt;/g, &#39;&amp;lt;&#39;)
          .replace(/&gt;/g, &#39;&amp;gt;&#39;)
          .replace(/&quot;/g, &#39;&amp;quot;&#39;)
          .replace(/&#39;/g, &#39;&amp;#039;&#39;);
      }
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</pre></section><section><h3>실습용 API KEY</h3><pre>AIzaSyDETqF5gzbFh3DG4U0N-rjT1yK2asM-1X4</pre></section><h3>관련 링크</h3><ul><li><a href="https://brunch.co.kr/@mystoryg/156">Youtube Data API 키 발급 방법 안내</a></li></ul></section><section><h2>(3) 추가 강의자료</h2><h3>교안 및 학습자료</h3><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F1785553810247_2127719__%EA%B0%95%EC%9D%98_%EA%B9%80%EB%AF%BC%EA%B8%B0.pdf?alt=media&amp;token=cf4e7382-2f7a-4cff-a841-c164f4ff26e6">[강의] 김민기.pdf</a></li></ul></section><section><h2>(4) 민기쌤 생기부 도우미</h2><section><h3>Code.gs</h3><pre>// =================================================================
// [0] 초기 설정 및 메뉴
// =================================================================

function onOpen() {
  SpreadsheetApp.getUi().createMenu(&#39;민기쌤 생기부 도우미&#39;)
      .addItem(&#39;🚀 사이드바 열기&#39;, &#39;openSidebar&#39;) 
      .addToUi();
}

function renderView_(viewName) {
  var template = HtmlService.createTemplateFromFile(&#39;Sidebar&#39;);
  template.initialView = viewName || &#39;sidebar&#39;;
  return template.evaluate();
}

function openSidebar() {
  ensureBannedWordSheet_();
  var html = renderView_(&#39;sidebar&#39;)
      .setTitle(&#39;민기쌤 생기부 맞춤법 도우미&#39;)
      .setWidth(300); 
  SpreadsheetApp.getUi().showSidebar(html);
}

function openSpellCheckViewer() {
  var html = renderView_(&#39;spellcheck&#39;)
      .setWidth(1000) 
      .setHeight(700);
  SpreadsheetApp.getUi().showModelessDialog(html, &#39;📝 Bareun 맞춤법 검사 및 수정&#39;);
}

function openBannedWordViewer() {
  var html = renderView_(&#39;banned&#39;)
      .setWidth(1000)
      .setHeight(700);
  SpreadsheetApp.getUi().showModelessDialog(html, &#39;🚫 기재 금지어 점검&#39;);
}

// =================================================================
// [1] Bareun API 키 및 UI 설정
// =================================================================

function getBareunUserProperties_() {
  ensureBannedWordSheet_();
  var props = PropertiesService.getUserProperties();
  var currentPolicy = &#39;BAREUN_ONLY_KEYS_V3&#39;;
  var previousPolicy = props.getProperty(&#39;API_KEY_POLICY_VERSION&#39;);

  // 정책 변경 시 현재 기능에서 쓰지 않는 예전 사용자 설정을 한 번 정리합니다.
  if (previousPolicy !== currentPolicy) {
    var allowedProperties = {
      UI_STATE: true,
      API_KEY_POLICY_VERSION: true
    };
    // 바로 이전 사용자 입력형 정책에서 저장한 키만 보존합니다.
    if (previousPolicy === &#39;USER_SUPPLIED_KEYS_V1&#39; ||
        previousPolicy === &#39;BAREUN_ONLY_KEYS_V1&#39; ||
        previousPolicy === &#39;BAREUN_ONLY_KEYS_V2&#39;) {
      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(&#39;UI_STATE&#39;) || &#39;{}&#39;);
    } catch (e) {
      previousUiState = {};
    }
    var currentUiState = {};
    [&#39;card-api&#39;, &#39;sub-api-settings&#39;, &#39;card-util&#39;].forEach(function(id) {
      if (previousUiState[id]) currentUiState[id] = true;
    });
    props.setProperty(&#39;UI_STATE&#39;, JSON.stringify(currentUiState));
    props.setProperty(&#39;API_KEY_POLICY_VERSION&#39;, currentPolicy);
  }

  return props;
}

function saveUserBareunKey(bareunKey) {
  var props = getBareunUserProperties_();
  var cleanBareunKey = String(bareunKey || &#39;&#39;).trim();

  if (!cleanBareunKey) {
    throw new Error(&#39;Bareun API 키를 입력해 주세요. 이 도우미는 API 키를 제공하지 않습니다.&#39;);
  }

  props.setProperty(&#39;USER_BAREUN_KEY&#39;, cleanBareunKey);
  return { hasBareun: true };
}

function checkUserBareunStatus() {
  var props = getBareunUserProperties_();
  return { hasBareun: !!props.getProperty(&#39;USER_BAREUN_KEY&#39;) };
}

function clearUserBareunKey() {
  var props = getBareunUserProperties_();
  props.deleteProperty(&#39;USER_BAREUN_KEY&#39;);
  return { hasBareun: false };
}

function getBareunKey() {
  var key = getBareunUserProperties_().getProperty(&#39;USER_BAREUN_KEY&#39;);
  if (!key) throw new Error(&quot;Bareun API 키가 없습니다. 사이드바 상단에서 등록해주세요.&quot;);
  return key;
}

// =================================================================
// [2] Bareun 맞춤법 검사
// =================================================================

function getSelectedDataForSpellCheck() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var sheetName = sheet.getName();
  var range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);
  var rows = [];
  var startRow = range.getRow();
  var startCol = range.getColumn();
  var values = range.getValues();
  for (var i = 0; i &lt; values.length; i++) {
    for (var j = 0; j &lt; values[0].length; j++) {
      var text = values[i][j].toString();
      if (text.trim() !== &quot;&quot;) {
        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(&#39;검사한 시트를 찾을 수 없습니다: &#39; + item.sheetName);
    sheet.getRange(item.row, item.col).setValue(item.text);
  });
  return updates.length;
}

function callBareunApi(text, userKey) {
  var url = &quot;https://api.bareun.ai/bareun.RevisionService/CorrectError&quot;;
  var options = {
    &quot;method&quot;: &quot;post&quot;, &quot;contentType&quot;: &quot;application/json&quot;, &quot;headers&quot;: { &quot;api-key&quot;: userKey }, &quot;muteHttpExceptions&quot;: true,
    &quot;payload&quot;: JSON.stringify({ &quot;document&quot;: { &quot;content&quot;: text, &quot;language&quot;: &quot;ko-KR&quot; }, &quot;encoding_type&quot;: &quot;UTF16&quot; })
  };
  var response = UrlFetchApp.fetch(url, options);
  var result = JSON.parse(response.getContentText());
  
  if (response.getResponseCode() !== 200) {
    return { error: &quot;API 오류: &quot; + response.getResponseCode() };
  }
  return { revised: result.revised, helps: result.helps || {}, rawBlocks: result.revisedBlocks || result.revised_blocks || [], error: null };
}

// =================================================================
// [3] 기재 금지어 관리
// =================================================================

var BANNED_WORD_SHEET_NAME_ = &#39;기재금지어관리&#39;;
var BANNED_WORD_HEADERS_ = [&#39;번호&#39;, &#39;기재 금지어&#39;];
var LEGACY_BANNED_WORD_HEADERS_ = [&#39;유형&#39;, &#39;기재 금지어&#39;, &#39;대체 표현&#39;];
var ONE_COLUMN_BANNED_WORD_HEADERS_ = [&#39;기재 금지어&#39;];

function getLegacyBannedWordList_() {
  var saved = PropertiesService.getUserProperties().getProperty(&#39;BANNED_WORDS_DB&#39;);
  if (!saved) return getDefaultBannedList_();

  try {
    var parsed = JSON.parse(saved);
    if (!Array.isArray(parsed)) return getDefaultBannedList_();
    return parsed
      .map(function(item) {
        return String(item &amp;&amp; item.k || &#39;&#39;).trim();
      })
      .filter(function(keyword) { return keyword !== &#39;&#39;; });
  } catch (e) {
    return getDefaultBannedList_();
  }
}

function initializeBannedWordSheet_(sheet, list) {
  sheet.getRange(1, 1, 1, BANNED_WORD_HEADERS_.length)
    .setValues([BANNED_WORD_HEADERS_])
    .setBackground(&#39;#fce8e6&#39;)
    .setFontColor(&#39;#a50e0e&#39;)
    .setFontWeight(&#39;bold&#39;)
    .setHorizontalAlignment(&#39;center&#39;);

  var rows = list.map(function(keyword, index) {
    return [index + 1, keyword];
  });
  if (rows.length &gt; 0) sheet.getRange(2, 1, rows.length, 2).setValues(rows);

  sheet.setFrozenRows(1);
  sheet.setColumnWidth(1, 60);
  sheet.setColumnWidth(2, 260);
  sheet.setTabColor(&#39;#d93025&#39;);
}

function migrateLegacyBannedWordSheet_(sheet) {
  var lastColumn = sheet.getLastColumn();
  var legacyColumn;
  var clearColumns;

  if (lastColumn &gt;= 3 &amp;&amp; sheet.getRange(1, 1, 1, 3).getDisplayValues()[0].join(&#39;|&#39;) === LEGACY_BANNED_WORD_HEADERS_.join(&#39;|&#39;)) {
    legacyColumn = 2;
    clearColumns = 3;
  } else if (sheet.getRange(1, 1, 1, 1).getDisplayValues()[0][0] === ONE_COLUMN_BANNED_WORD_HEADERS_[0]) {
    legacyColumn = 1;
    clearColumns = 1;
  } else {
    return false;
  }

  var lastRow = sheet.getLastRow();
  var keywords = lastRow &lt; 2 ? [] : sheet.getRange(2, legacyColumn, lastRow - 1, 1)
    .getDisplayValues()
    .map(function(row) { return String(row[0] || &#39;&#39;).trim(); })
    .filter(function(keyword) { return keyword !== &#39;&#39;; });

  var clearRows = Math.max(sheet.getLastRow(), 1);
  sheet.getRange(1, 1, clearRows, clearColumns).clearContent();
  initializeBannedWordSheet_(sheet, keywords);
  return true;
}

function ensureBannedWordSheet_() {
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  if (!spreadsheet) throw new Error(&#39;활성 스프레드시트를 찾을 수 없습니다.&#39;);

  var originalSheet = spreadsheet.getActiveSheet();
  var originalRange = spreadsheet.getActiveRange();
  var lock = LockService.getDocumentLock() || LockService.getScriptLock();
  lock.waitLock(30000);

  var sheet;
  try {
    sheet = spreadsheet.getSheetByName(BANNED_WORD_SHEET_NAME_);
    if (!sheet) {
      sheet = spreadsheet.insertSheet(BANNED_WORD_SHEET_NAME_);
      initializeBannedWordSheet_(sheet, getLegacyBannedWordList_());
    } else {
      // 이전 형식은 금지어만 보존하여 번호·기재금지어 두 열 목록으로 안전하게 바꿉니다.
      migrateLegacyBannedWordSheet_(sheet);
    }

    // 시트로 옮긴 뒤 예전 사용자별 저장값은 더 이상 별도 원본으로 남기지 않습니다.
    PropertiesService.getUserProperties().deleteProperty(&#39;BANNED_WORDS_DB&#39;);
    return sheet;
  } finally {
    if (originalSheet &amp;&amp; sheet &amp;&amp; originalSheet.getSheetId() !== sheet.getSheetId()) {
      spreadsheet.setActiveSheet(originalSheet);
      if (originalRange) spreadsheet.setActiveRange(originalRange);
    }
    lock.releaseLock();
  }
}

function openBannedWordManagementSheet() {
  var sheet = ensureBannedWordSheet_();
  sheet.activate();
  sheet.getRange(&#39;A1&#39;).activate();
  return sheet.getName();
}

function getDefaultBannedList_() {
  return [
    &quot;MBTI&quot;, &quot;커리어넷&quot;, &quot;메이저맵&quot;, &quot;패들렛&quot;, &quot;띵커벨&quot;, &quot;알로&quot;,
    &quot;미리캔버스&quot;, &quot;망고보드&quot;, &quot;유튜버&quot;, &quot;유튜브&quot;, &quot;넷플릭스&quot;, &quot;왓챠&quot;,
    &quot;티빙&quot;, &quot;네이버 폼&quot;, &quot;구글 폼&quot;, &quot;OECD&quot;, &quot;UN&quot;, &quot;유네스코&quot;, &quot;WTO&quot;,
    &quot;WHO&quot;, &quot;그린피스&quot;, &quot;앰네스티&quot;, &quot;IMF&quot;, &quot;인스타그램&quot;, &quot;페이스북&quot;,
    &quot;카카오톡&quot;, &quot;네이버&quot;, &quot;구글&quot;, &quot;클래스 카드&quot;, &quot;구글 클래스룸&quot;, &quot;게더타운&quot;,
    &quot;제페토&quot;, &quot;아이패드&quot;, &quot;갤럭시 탭&quot;, &quot;비트코인&quot;, &quot;TED&quot;, &quot;반크&quot;, &quot;TOEIC&quot;,
    &quot;TOEFL&quot;, &quot;TEPS&quot;, &quot;대회&quot;, &quot;수상&quot;, &quot;한자능력검정&quot;
  ];
}

function getBannedWordList_() {
  var sheet = ensureBannedWordSheet_();
  var header = sheet.getRange(1, 1, 1, 2).getDisplayValues()[0];
  if (header.join(&#39;|&#39;) !== BANNED_WORD_HEADERS_.join(&#39;|&#39;)) {
    throw new Error(&#39;기재금지어관리 시트의 1행은 &quot;번호 | 기재 금지어&quot; 순서여야 합니다.&#39;);
  }

  var lastRow = sheet.getLastRow();
  if (lastRow &lt; 2) return [];

  var values = sheet.getRange(2, 1, lastRow - 1, 2).getDisplayValues();
  var seen = {};
  var list = [];

  values.forEach(function(row, index) {
    var rowNumber = index + 2;
    var number = String(row[0] || &#39;&#39;).trim();
    var keyword = String(row[1] || &#39;&#39;).trim();

    if (!number &amp;&amp; !keyword) return;
    if (!/^[1-9]\d*$/.test(number)) {
      throw new Error(&#39;기재금지어관리 시트 &#39; + rowNumber + &#39;행의 번호(A열)는 1 이상의 정수여야 합니다.&#39;);
    }
    if (!keyword) throw new Error(&#39;기재금지어관리 시트 &#39; + rowNumber + &#39;행의 기재 금지어(B열)를 입력해 주세요.&#39;);

    var duplicateKey = normalizeBannedWord_(keyword);
    if (seen[duplicateKey]) {
      throw new Error(&#39;기재금지어관리 시트 &#39; + rowNumber + &#39;행의 기재 금지어가 &#39; + seen[duplicateKey] + &#39;행과 중복됩니다: &#39; + keyword);
    }
    seen[duplicateKey] = rowNumber;
    list.push(keyword);
  });

  return list;
}

function runBannedWordCheck() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var sheetName = sheet.getName();
  var range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);

  var values = range.getValues();
  var startRow = range.getRow();
  var startCol = range.getColumn();
  
  var db = getBannedWordList_();
  if (db.length === 0) return [];
  db.sort(function(a, b) { return normalizeBannedWord_(b).length - normalizeBannedWord_(a).length; });
  var canonicalByNormalizedKeyword = {};
  db.forEach(function(keyword) { canonicalByNormalizedKeyword[normalizeBannedWord_(keyword)] = keyword; });
  var combinedPattern = new RegExp(&#39;(&#39; + db.map(buildWhitespaceInsensitivePattern_).join(&#39;|&#39;) + &#39;)&#39;, &#39;gi&#39;);

  var results = [];

  for (var i = 0; i &lt; values.length; i++) {
    for (var j = 0; j &lt; values[0].length; j++) {
      var original = values[i][j].toString();
      if (!original.trim()) continue;

      var hasIssue = false;
      var logs = [];

      var counts = {};
      var match;
      while ((match = combinedPattern.exec(original)) !== null) {
        var normalizedMatch = normalizeBannedWord_(match[0]);
        var keyword = canonicalByNormalizedKeyword[normalizedMatch];
        if (keyword) {
          hasIssue = true;
          counts[keyword] = (counts[keyword] || 0) + 1;
        }
      }

      Object.keys(counts).forEach(function(keyword) {
        logs.push({ k: keyword, count: counts[keyword] });
      });

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

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

function normalizeBannedWord_(value) {
  return String(value || &#39;&#39;).replace(/\s+/g, &#39;&#39;).toLocaleLowerCase();
}

function buildWhitespaceInsensitivePattern_(keyword) {
  var compact = normalizeBannedWord_(keyword);
  if (!compact) throw new Error(&#39;비어 있는 기재 금지어는 검사할 수 없습니다.&#39;);
  return compact.split(&#39;&#39;).map(function(character) {
    return escapeRegExp(character);
  }).join(&#39;[\\s\\u00A0]*&#39;);
}

// =================================================================
// [4] 로컬 검토 유틸리티
// =================================================================

function unifyToStraightQuotes() {
  var range = SpreadsheetApp.getActiveRange();
  var ui = SpreadsheetApp.getUi();
  if (!range) throw new Error(&#39;선택된 셀이 없습니다.&#39;);
  var response = ui.alert(&#39;따옴표 변환&#39;, &#39;선택 영역의 둥근 따옴표를 곧은 따옴표로 변환하시겠습니까?&#39;, ui.ButtonSet.YES_NO);
  if (response != ui.Button.YES) return;
  try {
    range.createTextFinder(&#39;[‘’]&#39;).useRegularExpression(true).matchEntireCell(false).replaceAllWith(&quot;&#39;&quot;);
    range.createTextFinder(&#39;[“”]&#39;).useRegularExpression(true).matchEntireCell(false).replaceAllWith(&#39;&quot;&#39;);
    SpreadsheetApp.flush();
    ui.alert(&#39;✅ 변환 완료&#39;);
  } catch (e) { ui.alert(&#39;오류: &#39; + e.toString()); }
}

// [신규] 선택 영역 스페이스바 실수(2칸 이상) 수정 함수
function fixDoubleSpaces() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);

  const values = range.getValues();
  let fixCount = 0; // 수정된 셀 개수 카운트

  const newValues = values.map(row =&gt; {
    return row.map(cell =&gt; {
      let str = String(cell);
      // 공백이 2번 이상 연속( /  +/)으로 있으면 1번으로 변경
      // 줄바꿈(\n)은 건드리지 않도록 일반 공백( )만 타겟팅
      if (/ {2,}/.test(str)) {
        fixCount++;
        return str.replace(/ {2,}/g, &#39; &#39;); 
      }
      return cell;
    });
  });

  if (fixCount &gt; 0) {
    range.setValues(newValues);
  }
  
  return fixCount;
}

// =================================================================
// [5] UI 상태 저장/불러오기
// =================================================================

function saveUiState(cardId, isExpanded) {
  var props = PropertiesService.getUserProperties();
  var state = JSON.parse(props.getProperty(&#39;UI_STATE&#39;) || &#39;{}&#39;);
  state[cardId] = isExpanded;
  props.setProperty(&#39;UI_STATE&#39;, JSON.stringify(state));
}

function getUiState() {
  var props = PropertiesService.getUserProperties();
  return JSON.parse(props.getProperty(&#39;UI_STATE&#39;) || &#39;{}&#39;);
}
</pre></section><section><h3>Sidebar.html</h3><pre>&lt;? var allowedViews = [&quot;sidebar&quot;, &quot;spellcheck&quot;, &quot;banned&quot;]; ?&gt;
&lt;? var activeView = allowedViews.indexOf(initialView) &gt;= 0 ? initialView : &quot;sidebar&quot;; ?&gt;
&lt;? if (activeView === &#39;sidebar&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;style&gt;
      * { box-sizing: border-box; }
      body {
        margin: 0;
        padding: 10px 15px 10px 2px;
        background: #f1f3f4;
        color: #202124;
        font-family: &#39;Noto Sans KR&#39;, 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=&quot;password&quot;] {
        width: 100%;
        padding: 10px;
        border: 2px solid #dadce0;
        border-radius: 6px;
        outline: none;
        font-family: inherit;
        font-size: 14px;
      }
      input[type=&quot;password&quot;]: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; }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div class=&quot;card&quot; id=&quot;card-api&quot;&gt;
      &lt;div class=&quot;card-header&quot; onclick=&quot;toggleCard(this)&quot;&gt;
        &lt;div class=&quot;header-title&quot;&gt;🔑 Bareun API 키 설정&lt;/div&gt;
        &lt;span class=&quot;material-icons toggle-icon&quot;&gt;expand_more&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class=&quot;card-body&quot;&gt;
        &lt;div class=&quot;sub-section&quot; id=&quot;sub-api-settings&quot;&gt;
          &lt;div class=&quot;sub-header&quot; onclick=&quot;toggleSub(this)&quot;&gt;
            &lt;span&gt;내 Bareun API 키&lt;/span&gt;
            &lt;span class=&quot;material-icons sub-icon&quot;&gt;expand_more&lt;/span&gt;
          &lt;/div&gt;
          &lt;div class=&quot;sub-body&quot;&gt;
            &lt;div id=&quot;api-loading&quot; class=&quot;api-status&quot;&gt;⌛ API 키 설정 확인 중...&lt;/div&gt;
            &lt;div id=&quot;api-settings&quot; class=&quot;hidden&quot;&gt;
              &lt;div id=&quot;apiStatusSummary&quot; class=&quot;api-status&quot;&gt;&lt;/div&gt;
              &lt;label class=&quot;input-label&quot; for=&quot;bareunKeyInput&quot;&gt;Bareun API 키&lt;/label&gt;
              &lt;input type=&quot;password&quot; id=&quot;bareunKeyInput&quot; autocomplete=&quot;off&quot; placeholder=&quot;본인이 발급받은 키를 입력하세요&quot;&gt;
              &lt;div class=&quot;key-help&quot;&gt;공용 키는 제공하지 않습니다. 키는 사용자별 설정에 저장되고 원문을 화면으로 다시 불러오지 않습니다.&lt;/div&gt;
              &lt;button class=&quot;btn-auth&quot; id=&quot;saveKeyBtn&quot; onclick=&quot;saveBareunKey()&quot;&gt;내 Bareun 키 저장&lt;/button&gt;
              &lt;button class=&quot;btn-secondary hidden&quot; id=&quot;clearKeyBtn&quot; onclick=&quot;resetBareunKey()&quot;&gt;저장된 Bareun 키 삭제&lt;/button&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;card expanded&quot; id=&quot;card-util&quot;&gt;
      &lt;div class=&quot;card-header&quot; onclick=&quot;toggleCard(this)&quot;&gt;
        &lt;div class=&quot;header-title&quot;&gt;📝 선택 영역 검사 및 정리&lt;/div&gt;
        &lt;span class=&quot;material-icons toggle-icon&quot;&gt;expand_more&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class=&quot;card-body&quot;&gt;
        &lt;button class=&quot;btn-warning&quot; id=&quot;checkBtn&quot; onclick=&quot;runSpellCheck()&quot;&gt;선택 영역 Bareun 맞춤법 검사 🔍&lt;/button&gt;
        &lt;div id=&quot;checkStatus&quot; class=&quot;status-box&quot;&gt;&lt;/div&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;runSpaceFix(this)&quot;&gt;선택 영역 연속 공백 정리&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;convertQuotes(this)&quot;&gt;선택 영역 따옴표 통일 (&#39; &quot;)&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;runBannedCheck()&quot;&gt;선택 영역 기재 금지어 검사&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;openBannedWordManagement()&quot;&gt;기재금지어관리 시트 열기&lt;/button&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let currentApiStatus = { hasBareun: false };

      function updateApiState(status) {
        currentApiStatus = { hasBareun: !!(status &amp;&amp; status.hasBareun) };

        const summary = document.getElementById(&#39;apiStatusSummary&#39;);
        const clearButton = document.getElementById(&#39;clearKeyBtn&#39;);
        const spellCheckButton = document.getElementById(&#39;checkBtn&#39;);
        const apiCard = document.getElementById(&#39;card-api&#39;);
        const apiSub = document.getElementById(&#39;sub-api-settings&#39;);

        summary.innerHTML = currentApiStatus.hasBareun
          ? &#39;&lt;div class=&quot;ok&quot;&gt;✅ Bareun 키 저장됨&lt;/div&gt;&#39;
          : &#39;&lt;div class=&quot;missing&quot;&gt;❌ 맞춤법 검사를 사용하려면 Bareun 키가 필요합니다.&lt;/div&gt;&#39;;
        clearButton.classList.toggle(&#39;hidden&#39;, !currentApiStatus.hasBareun);
        spellCheckButton.disabled = !currentApiStatus.hasBareun;
        spellCheckButton.title = currentApiStatus.hasBareun ? &#39;&#39; : &#39;Bareun API 키를 먼저 저장해 주세요.&#39;;

        apiCard.classList.toggle(&#39;expanded&#39;, !currentApiStatus.hasBareun);
        apiSub.classList.toggle(&#39;expanded&#39;, !currentApiStatus.hasBareun);
      }

      function toggleCard(header) {
        const card = header.parentElement;
        const isExpanded = card.classList.toggle(&#39;expanded&#39;);
        google.script.run.saveUiState(card.id, isExpanded);
      }

      function toggleSub(header) {
        const section = header.parentElement;
        const isExpanded = section.classList.toggle(&#39;expanded&#39;);
        google.script.run.saveUiState(section.id, isExpanded);
      }

      function saveBareunKey() {
        const button = document.getElementById(&#39;saveKeyBtn&#39;);
        const keyInput = document.getElementById(&#39;bareunKeyInput&#39;);
        const key = keyInput.value.trim();
        if (!key) {
          alert(&#39;Bareun API 키를 입력해 주세요.&#39;);
          return;
        }

        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;저장 중...&#39;;

        google.script.run
          .withSuccessHandler(function(status) {
            keyInput.value = &#39;&#39;;
            updateApiState(status);
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;✅ Bareun API 키를 사용자 설정에 저장했습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;Bareun API 키 저장 오류: &#39; + error);
          })
          .saveUserBareunKey(key);
      }

      function resetBareunKey() {
        if (!confirm(&#39;저장된 Bareun API 키를 삭제하시겠습니까?&#39;)) return;

        google.script.run
          .withSuccessHandler(function(status) {
            document.getElementById(&#39;bareunKeyInput&#39;).value = &#39;&#39;;
            updateApiState(status);
            alert(&#39;저장된 Bareun API 키를 삭제했습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            alert(&#39;Bareun API 키 삭제 오류: &#39; + error);
          })
          .clearUserBareunKey();
      }

      function runSpellCheck() {
        if (!currentApiStatus.hasBareun) {
          alert(&#39;Bareun API 키를 먼저 저장해 주세요.&#39;);
          return;
        }
        document.getElementById(&#39;checkStatus&#39;).innerText = &#39;맞춤법 검사 창을 여는 중...&#39;;
        google.script.run
          .withSuccessHandler(function() {
            document.getElementById(&#39;checkStatus&#39;).innerText = &#39;&#39;;
          })
          .withFailureHandler(function(error) {
            document.getElementById(&#39;checkStatus&#39;).innerText = &#39;&#39;;
            alert(&#39;오류 발생: &#39; + error);
          })
          .openSpellCheckViewer();
      }

      function runSpaceFix(button) {
        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;정리 중...&#39;;
        google.script.run
          .withSuccessHandler(function(count) {
            button.disabled = false;
            button.innerText = originalText;
            alert(count ? &#39;✅ 총 &#39; + count + &#39;개 셀의 연속 공백을 정리했습니다.&#39; : &#39;수정할 연속 공백이 없습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;오류 발생: &#39; + error);
          })
          .fixDoubleSpaces();
      }

      function convertQuotes(button) {
        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;변환 중...&#39;;
        google.script.run
          .withSuccessHandler(function() {
            button.disabled = false;
            button.innerText = originalText;
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;오류 발생: &#39; + error);
          })
          .unifyToStraightQuotes();
      }

      function runBannedCheck() {
        google.script.run
          .withFailureHandler(function(error) { alert(&#39;오류 발생: &#39; + error); })
          .openBannedWordViewer();
      }

      function openBannedWordManagement() {
        google.script.run
          .withFailureHandler(function(error) { alert(&#39;오류 발생: &#39; + error); })
          .openBannedWordManagementSheet();
      }

      window.onload = function() {
        google.script.run
          .withSuccessHandler(function(status) {
            document.getElementById(&#39;api-loading&#39;).classList.add(&#39;hidden&#39;);
            document.getElementById(&#39;api-settings&#39;).classList.remove(&#39;hidden&#39;);
            updateApiState(status);

            google.script.run.withSuccessHandler(function(states) {
              Object.keys(states || {}).forEach(function(id) {
                const element = document.getElementById(id);
                if (id === &#39;card-api&#39; || id === &#39;sub-api-settings&#39;) return;
                if (element &amp;&amp; states[id]) element.classList.add(&#39;expanded&#39;);
              });
              updateApiState(status);
            }).getUiState();
          })
          .withFailureHandler(function(error) {
            document.getElementById(&#39;api-loading&#39;).classList.add(&#39;hidden&#39;);
            document.getElementById(&#39;api-settings&#39;).classList.remove(&#39;hidden&#39;);
            updateApiState({ hasBareun: false });
            document.getElementById(&#39;apiStatusSummary&#39;).textContent = &#39;설정 확인 오류: &#39; + error;
          })
          .checkUserBareunStatus();
      };
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } else if (activeView === &#39;spellcheck&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch.js&quot;&gt;&lt;/script&gt;
    &lt;style&gt;
      body { padding: 20px; font-family: &#39;Noto Sans KR&#39;, 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; }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;

    &lt;div class=&quot;header-area&quot;&gt;
      &lt;div class=&quot;title&quot;&gt;&lt;span class=&quot;material-icons&quot;&gt;fact_check&lt;/span&gt; 맞춤법 검사 및 수정&lt;/div&gt;
      &lt;div style=&quot;font-size:12px; color:#5f6368; display:flex; gap:15px;&quot;&gt;
        &lt;div&gt;&lt;span style=&quot;background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;&quot;&gt;원본 오류&lt;/span&gt;&lt;/div&gt;
        &lt;div&gt;&lt;span style=&quot;background:#e6f4ea; color:#188038; border-bottom:2px solid #188038; padding:0 4px; font-weight:500;&quot;&gt;수정 제안&lt;/span&gt;&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div id=&quot;mainContent&quot; class=&quot;content-area&quot;&gt;
      &lt;div id=&quot;loaderOverlay&quot; class=&quot;loader-overlay&quot;&gt;
        &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
        &lt;div id=&quot;loadingText&quot;&gt;데이터를 불러오는 중입니다...&lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;cardsContainer&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;footer-bar&quot;&gt;
      &lt;button class=&quot;btn btn-cancel&quot; onclick=&quot;google.script.host.close()&quot;&gt;닫기&lt;/button&gt;
      &lt;button class=&quot;btn btn-apply&quot; id=&quot;btnApply&quot; onclick=&quot;applyAll()&quot; disabled&gt;적용하기 (0건)&lt;/button&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let itemData = []; 
      let isFirstRender = true; 

      window.onload = function() {
        google.script.run
          .withSuccessHandler(processData)
          .withFailureHandler(function(err) {
             document.getElementById(&#39;loaderOverlay&#39;).innerHTML = 
               `&lt;div class=&quot;error-msg&quot;&gt;❌ 초기화 오류&lt;br&gt;${err}&lt;br&gt;선택된 셀이 있는지 확인해주세요.&lt;/div&gt;`;
          })
          .getSelectedDataForSpellCheck();
      };

      function processData(rows) {
        if (rows.length === 0) {
          document.getElementById(&#39;loaderOverlay&#39;).innerHTML = &#39;&lt;div class=&quot;error-msg&quot;&gt;선택된 셀이 없거나 빈 셀입니다.&lt;/div&gt;&#39;;
          return;
        }

        document.getElementById(&#39;loadingText&#39;).innerText = `총 ${rows.length}개 항목 분석 중...`;
        const container = document.getElementById(&#39;cardsContainer&#39;);
        container.innerHTML = &quot;&quot;; 
        
        let processedCount = 0;
        const total = rows.length;

        rows.forEach((row, index) =&gt; {
          const div = document.createElement(&#39;div&#39;);
          div.id = `item-${index}`;
          div.className = &#39;check-item&#39;;
          div.innerHTML = `
            &lt;div class=&quot;item-header&quot;&gt;&lt;span&gt;행 ${row.row} (대기 중...)&lt;/span&gt;&lt;/div&gt;
            &lt;div class=&quot;split-view&quot;&gt;&lt;div class=&quot;pane pane-left&quot;&gt;...&lt;/div&gt;&lt;div class=&quot;pane pane-right&quot;&gt;...&lt;/div&gt;&lt;/div&gt;
          `;
          container.appendChild(div);

          google.script.run
            .withSuccessHandler((res) =&gt; {
              renderResult(index, row, res);
              processedCount++;
              updateApplyButton(processedCount, total);
            })
            .withFailureHandler((err) =&gt; {
              renderError(index, row, err);
              processedCount++;
              updateApplyButton(processedCount, total);
            })
            .getBareunCorrection(row.original);
        });
      }

      function renderError(index, rowData, errorMsg) {
        if (isFirstRender) {
          document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
          isFirstRender = false;
        }
        const itemDiv = document.getElementById(`item-${index}`);
        if (!itemDiv) return;
        itemDiv.innerHTML = `&lt;div style=&quot;padding:20px; color:red;&quot;&gt;❌ 분석 실패: ${errorMsg}&lt;/div&gt;`;
      }

      function renderResult(index, rowData, apiResult) {
        if (isFirstRender) {
          document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
          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 = &quot;&quot;;
        const analysisList = [];
        if (apiResult.rawBlocks &amp;&amp; apiResult.rawBlocks.length &gt; 0) {
          traverseBlocks(apiResult.rawBlocks, apiResult.helps, analysisList);
        }

        if (analysisList.length &gt; 0) {
          const listItems = analysisList.map(item =&gt; `
            &lt;li class=&quot;analysis-row&quot;&gt;
              &lt;span class=&quot;err-word&quot;&gt;${item.origin}&lt;/span&gt;
              &lt;span class=&quot;arrow&quot;&gt;→&lt;/span&gt;
              &lt;span class=&quot;fix-word&quot;&gt;${item.revised}&lt;/span&gt;
              &lt;span class=&quot;fix-reason&quot;&gt;${item.msg}&lt;/span&gt;
            &lt;/li&gt;
          `).join(&quot;&quot;);

          helpListHtml = `
            &lt;div class=&quot;analysis-box&quot;&gt;
              &lt;div class=&quot;analysis-title&quot;&gt;&lt;span class=&quot;material-icons&quot; style=&quot;font-size:14px;&quot;&gt;info&lt;/span&gt; 상세 수정 내역 (${analysisList.length}건)&lt;/div&gt;
              &lt;ul class=&quot;analysis-list&quot;&gt;${listItems}&lt;/ul&gt;
            &lt;/div&gt;
          `;
        }

        itemDiv.innerHTML = `
          &lt;div class=&quot;item-header&quot;&gt;
            &lt;span&gt;행 ${rowData.row}&lt;/span&gt;
            &lt;span style=&quot;color:#1a73e8;&quot;&gt;분석 완료&lt;/span&gt;
          &lt;/div&gt;
          &lt;div class=&quot;split-view&quot;&gt;
            &lt;div class=&quot;pane pane-left&quot;&gt;${diff.left}&lt;/div&gt;
            &lt;div class=&quot;pane pane-right&quot; contenteditable=&quot;true&quot; spellcheck=&quot;false&quot; id=&quot;edit-${index}&quot; oninput=&quot;updateBytes(${index})&quot;&gt;${diff.right}&lt;/div&gt;
          &lt;/div&gt;
          ${helpListHtml}
          &lt;div class=&quot;byte-info&quot;&gt;
            &lt;div class=&quot;byte-box&quot; style=&quot;border-right:1px solid #eee;&quot;&gt;원본: ${getByteLength(rowData.original)} byte&lt;/div&gt;
            &lt;div class=&quot;byte-box&quot;&gt;수정본: &lt;span id=&quot;byte-${index}&quot; class=&quot;byte-val&quot;&gt;${getByteLength(apiResult.revised)}&lt;/span&gt; byte&lt;/div&gt;
          &lt;/div&gt;
        `;
        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 &amp;&amp; block.nested.length &gt; 0) {
            traverseBlocks(block.nested, helps, resultsArray);
          } else {
            const origin = block.origin.content || &quot;&quot;;
            const revised = block.revised || &quot;&quot;;
            if (origin === revised) return;
            let msg = &quot;교정&quot;;
            if (block.revisions &amp;&amp; block.revisions.length &gt; 0) {
              const helpId = block.revisions[0].helpId || block.revisions[0].help_id;
              if (helps &amp;&amp; !helps[helpId]) return;
              if (helpId &amp;&amp; helps &amp;&amp; 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 === &#39;undefined&#39;) {
           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 = &quot;&quot;;
          for (var i = 0; i &lt; 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 = &quot;&quot;;
        let rightHtml = &quot;&quot;;
        
        diffs.forEach(function(part) {
          var op = part[0]; 
          var chars = part[1];
          var text = &quot;&quot;;
          
          // 유니코드를 다시 원래 단어로 복원
          for (var i = 0; i &lt; chars.length; i++) {
            text += tokenArr[chars.charCodeAt(i)];
          }
          
          if (op === 0) {
            leftHtml += text; rightHtml += text;
          } else if (op === -1) {
            leftHtml += `&lt;span class=&quot;diff-del&quot;&gt;${text}&lt;/span&gt;`;
          } else if (op === 1) {
            rightHtml += `&lt;span class=&quot;diff-add&quot;&gt;${text}&lt;/span&gt;`;
          }
        });
        
        return { left: leftHtml, right: rightHtml };
      }

      function getByteLength(s) {
        let b = 0; if (!s) return 0;
        for (let i = 0; i &lt; s.length; i++) {
          const c = s.charCodeAt(i);
          if (c === 10) b += 2; else if (c &gt;&gt; 11) b += 3; else if (c &gt;&gt; 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(&#39;btnApply&#39;);
        btn.innerText = `적용하기 (${current} / ${total})`;
        if (current === total) btn.disabled = false;
      }

      function applyAll() {
        if (!confirm(&quot;검사된 내용으로 원본 데이터를 덮어쓰시겠습니까?\n(되돌릴 수 없습니다)&quot;)) return;
        const updates = [];
        itemData.forEach((item, index) =&gt; {
          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(&#39;btnApply&#39;);
        btn.disabled = true; btn.innerText = &quot;저장 중...&quot;;
        google.script.run.withSuccessHandler((count) =&gt; {
          alert(`✅ 총 ${count}건이 수정되었습니다.`);
          google.script.host.close();
        }).applySpellCheckResults(updates);
      }
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } else if (activeView === &#39;banned&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;style&gt;
      body { padding: 20px; font-family: &#39;Noto Sans KR&#39;, 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: &quot;•&quot;; 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); } }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;

    &lt;div class=&quot;header-area&quot;&gt;
      &lt;div class=&quot;title&quot;&gt;&lt;span class=&quot;material-icons&quot;&gt;block&lt;/span&gt; 기재 금지어 검사&lt;/div&gt;
      &lt;div id=&quot;checkLegend&quot; style=&quot;font-size:12px; color:#5f6368; display:flex; gap:15px;&quot;&gt;
        &lt;div&gt;&lt;span style=&quot;background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;&quot;&gt;금지어 발견&lt;/span&gt;&lt;/div&gt;
        &lt;div&gt;오른쪽 수정안에서 문맥에 맞게 직접 고쳐 주세요.&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div id=&quot;checkView&quot; class=&quot;content-area&quot;&gt;
      &lt;div id=&quot;loaderOverlay&quot; class=&quot;loader-overlay&quot;&gt;
        &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
        &lt;div id=&quot;loadingText&quot;&gt;데이터를 분석 중입니다...&lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;cardsContainer&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;footer-bar&quot; id=&quot;footerCheck&quot;&gt;
      &lt;button class=&quot;btn btn-cancel&quot; onclick=&quot;google.script.host.close()&quot;&gt;닫기&lt;/button&gt;
      &lt;button class=&quot;btn btn-apply&quot; id=&quot;btnApply&quot; onclick=&quot;applyAll()&quot; disabled&gt;적용하기 (0건)&lt;/button&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let itemData = []; 
      let renderQueue = [];
      const CHUNK_SIZE = 20;

      window.onload = function() {
        runCheck();
      };

      function runCheck() {
        document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;flex&#39;;
        document.getElementById(&#39;loadingText&#39;).innerText = &quot;데이터를 분석 중입니다...&quot;;
        document.getElementById(&#39;cardsContainer&#39;).innerHTML = &quot;&quot;;
        
        google.script.run
          .withSuccessHandler(startRenderSequence)
          .withFailureHandler(err =&gt; alert(&quot;오류: &quot; + err))
          .runBannedWordCheck();
      }

      function startRenderSequence(rows) {
        document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
        const container = document.getElementById(&#39;cardsContainer&#39;);
        
        if (!rows || rows.length === 0) {
          container.innerHTML = &#39;&lt;div style=&quot;text-align:center; padding:50px; color:#5f6368;&quot;&gt;금지어가 발견되지 않았습니다. 🎉&lt;/div&gt;&#39;;
          document.getElementById(&#39;btnApply&#39;).disabled = true;
          return;
        }

        itemData = rows; 
        renderQueue = [...rows]; 
        processRenderQueue(); 

        document.getElementById(&#39;btnApply&#39;).innerText = `적용하기 (${rows.length}건)`;
        document.getElementById(&#39;btnApply&#39;).disabled = false;
      }

      function processRenderQueue() {
        if (renderQueue.length === 0) return;

        const container = document.getElementById(&#39;cardsContainer&#39;);
        const chunk = renderQueue.splice(0, CHUNK_SIZE);
        
        chunk.forEach((row) =&gt; {
          const originalIndex = itemData.findIndex(r =&gt; r.row === row.row &amp;&amp; r.col === row.col);
          let logHtml = &quot;&quot;;
          if (row.logs &amp;&amp; row.logs.length &gt; 0) {
            const listItems = row.logs.map(log =&gt; {
               const count = log.count &gt; 1 ? ` (${log.count}회)` : &#39;&#39;;
               return `&lt;li class=&quot;analysis-row&quot;&gt;&lt;span&gt;&lt;span class=&quot;err-word&quot;&gt;${escapeHtml(log.k)}&lt;/span&gt;${count} 기재금지어가 발견되었습니다. 오른쪽 수정안에서 문맥에 맞게 직접 고쳐 주세요.&lt;/span&gt;&lt;/li&gt;`;
            }).join(&quot;&quot;);
            
            logHtml = `
              &lt;div class=&quot;analysis-box&quot;&gt;
                &lt;ul class=&quot;analysis-list&quot;&gt;${listItems}&lt;/ul&gt;
              &lt;/div&gt;
            `;
          }

          const div = document.createElement(&#39;div&#39;);
          div.className = &#39;check-item&#39;;
          div.innerHTML = `
            &lt;div class=&quot;item-header&quot;&gt;
              &lt;span&gt;행 ${row.row}&lt;/span&gt;
              &lt;span style=&quot;color:#d93025; font-weight:bold;&quot;&gt;금지어 발견&lt;/span&gt;
            &lt;/div&gt;
            &lt;div class=&quot;split-view&quot;&gt;
              &lt;div class=&quot;pane pane-left&quot;&gt;${highlightBannedWords(row.original, row.logs)}&lt;/div&gt;
              &lt;div class=&quot;pane pane-right&quot; contenteditable=&quot;true&quot; spellcheck=&quot;false&quot; id=&quot;edit-${originalIndex}&quot;&gt;&lt;/div&gt;
            &lt;/div&gt;
            ${logHtml}
          `;
          container.appendChild(div);
          // 원문을 그대로 넣습니다. 기재금지어 검사는 자동 대체·삭제를 전혀 하지 않습니다.
          document.getElementById(`edit-${originalIndex}`).textContent = row.original;
        });

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

      function escapeHtml(value) {
        return String(value)
          .replace(/&amp;/g, &#39;&amp;amp;&#39;)
          .replace(/&lt;/g, &#39;&amp;lt;&#39;)
          .replace(/&gt;/g, &#39;&amp;gt;&#39;)
          .replace(/&quot;/g, &#39;&amp;quot;&#39;)
          .replace(/&#39;/g, &#39;&amp;#39;&#39;);
      }

      function escapeRegExp(value) {
        return String(value).replace(/[.*+?^${}()|[\]\\]/g, &#39;\\$&amp;&#39;);
      }

      function highlightBannedWords(text, logs) {
        const keywords = (logs || []).map(log =&gt; log.k).filter(Boolean);
        if (keywords.length === 0) return escapeHtml(text);
        const fragments = keywords
          .sort((a, b) =&gt; compactBannedWord(b).length - compactBannedWord(a).length)
          .map(buildWhitespaceInsensitivePattern);
        const pattern = new RegExp(`(${fragments.join(&#39;|&#39;)})`, &#39;gi&#39;);
        return String(text).split(pattern).map((part, index) =&gt; {
          const safeText = escapeHtml(part);
          return index % 2 === 1 ? `&lt;span class=&quot;diff-del&quot;&gt;${safeText}&lt;/span&gt;` : safeText;
        }).join(&#39;&#39;);
      }

      function compactBannedWord(value) {
        return String(value || &#39;&#39;).replace(/\s+/g, &#39;&#39;).toLocaleLowerCase();
      }

      function buildWhitespaceInsensitivePattern(keyword) {
        return compactBannedWord(keyword)
          .split(&#39;&#39;)
          .map(escapeRegExp)
          .join(&#39;[\\s\\u00A0]*&#39;);
      }

      function applyAll() {
        if (!confirm(&quot;수정된 내용으로 원본을 덮어쓰시겠습니까?&quot;)) return;
        
        const updates = [];
        itemData.forEach((item, index) =&gt; {
          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) =&gt; {
           alert(`${c}건 수정 완료!`);
           google.script.host.close();
        }).applySpellCheckResults(updates);
      }

    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } ?&gt;
</pre></section></section><section><h2>(5) 각종 조사</h2><h3>관련 링크</h3><ul><li><a href="https://www.survey.co.kr/?ACCESS_KEY=6c6923abcaf64c4ba2b3&amp;grpid=&amp;UID=&amp;TYPE=1">2026년 인공지능 활용 선도교사 연수 효과성 진단(사후조사)</a></li><li><a href="https://www.survey.co.kr/?ACCESS_KEY=a94cf69c21804c59b403&amp;grpid=&amp;AREA=5">만족도 조사 (17시부터 가능)</a></li></ul></section>]]></description></item><item><title>2026 인공지능 활용 선도교사 양성연수 - 전북 익산</title><link>https://민기.한국/65</link><guid isPermaLink="true">https://민기.한국/65</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>2026 인공지능 활용 선도교사 양성연수 - 전북 익산</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-07-30</dd><dt>장소</dt><dd>건국대학교</dd></dl><p>인공지능 활용 선도교사를 위한 중등 공통과정의 핵심 내용을 다루는 연수입니다.</p><section><h2>(1) 클로바노트 음성 전사</h2><h3>관련 링크</h3><ul><li><a href="https://clovanote.naver.com/w/GL2ZnyzYqzF59Bi2XCsw/home">네이버 클로바노트</a></li><li><a href="https://drive.google.com/file/d/1bTxR-DyK3I4i1ZRCdyVb_lWFEobrtOKe/view">실습용 녹음 파일</a></li></ul></section><section><h2>(2) 유튜브 댓글 수집</h2><p>아래 블로그의 설명에 따라 Youtube Data API 키를 발급합니다. 어려운 경우 최후의 수단으로 다음 연수용 API KEY를 사용할 수 있습니다. 이 KEY는 오늘 이후 폐기 처리되므로 유튜브 댓글 수집기가 더이상 작동하지 않습니다. AIzaSyDETqF5gzbFh3DG4U0N-rjT1yK2asM-1X4</p><section><h3>Code.gs</h3><pre>const YT_CFG = Object.freeze({
  API_BASE: &#39;https://www.googleapis.com/youtube/v3&#39;,

  API_KEY_PROP: &#39;YT_API_KEY&#39;,
  STATE_PROP: &#39;YT_COMMENT_COLLECTOR_STATE&#39;,
  LAST_RESULT_PROP: &#39;YT_COMMENT_COLLECTOR_LAST_RESULT&#39;,
  CANCEL_REQUEST_PROP: &#39;YT_COMMENT_COLLECTOR_CANCEL_REQUEST&#39;,

  OUTPUT_SHEET_PREFIX: &#39;시트&#39;,
  INTERNAL_SHEET: &#39;_댓글수집_내부&#39;,
  LEGACY_LOG_SHEET: &#39;수집로그&#39;,

  CONTINUE_HANDLER: &#39;continueCommentCollection&#39;,
  STRUCTURED_ERROR_PREFIX: &#39;YT_STRUCTURED_ERROR::&#39;,
  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: [
    &#39;번호&#39;,
    &#39;채널명&#39;,
    &#39;영상 제목&#39;,
    &#39;댓글 작성자&#39;,
    &#39;댓글 내용&#39;,
    &#39;좋아요 수&#39;,
    &#39;작성 시각&#39;,
    &#39;글자수&#39;
  ],

  // 숨김 내부 시트의 열 구조입니다.
  INTERNAL_HEADERS: [
    &#39;comment_id&#39;,
    &#39;parent_comment_id&#39;,
    &#39;display_number&#39;,
    &#39;comment_type&#39;,
    &#39;reply_status&#39;,
    &#39;next_reply_index&#39;,
    &#39;total_reply_count&#39;,
    &#39;reply_page_token&#39;
  ],

  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(&#39;YouTube 댓글 수집&#39;)
    .addItem(&#39;사이드바 열기&#39;, &#39;showYouTubeCommentSidebar&#39;)
    .addSeparator()
    .addItem(&#39;중단 지점부터 실행&#39;, &#39;continueCommentCollection&#39;)
    .addItem(&#39;진행 중인 수집 취소&#39;, &#39;cancelCommentCollection&#39;)
    .addToUi();
}


/**
 * 오른쪽 사이드바를 엽니다.
 */
function showYouTubeCommentSidebar() {
  const html = HtmlService
    .createHtmlOutputFromFile(&#39;Sidebar&#39;)
    .setTitle(&#39;YouTube 댓글 수집기&#39;);

  SpreadsheetApp.getUi().showSidebar(html);
}


/**
 * 사이드바 초기 상태를 반환합니다.
 */
function getSidebarState() {
  const props = getUserProperties_();
  const apiKey = props.getProperty(YT_CFG.API_KEY_PROP) || &#39;&#39;;
  const activeState = loadState_();
  const lastResult = loadLastResult_();
  const cancelRequested =
    props.getProperty(YT_CFG.CANCEL_REQUEST_PROP) === &#39;true&#39;;

  const progress = activeState
    ? buildProgressForClient_(activeState, &#39;RUNNING&#39;)
    : lastResult
      ? buildProgressForClient_(lastResult, lastResult.status || &#39;COMPLETED&#39;)
      : null;

  if (progress &amp;&amp; activeState &amp;&amp; cancelRequested) {
    progress.stage = &#39;취소 처리 중&#39;;
    progress.message = &#39;현재 실행 구간이 끝나는 즉시 안전하게 중단됩니다.&#39;;
  }

  return {
    apiKeyConfigured: Boolean(apiKey),
    apiKeyMask: apiKey ? maskApiKey_(apiKey) : &#39;&#39;,
    running: Boolean(activeState),
    cancelRequested: cancelRequested,
    progress: progress
  };
}


/**
 * API 키를 실제 YouTube Data API 요청으로 검증한 뒤 사용자별 속성에 저장합니다.
 */
function saveApiKey(apiKey) {
  const cleanedKey = String(apiKey || &#39;&#39;).trim();

  if (!cleanedKey) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_REQUIRED&#39;,
      &#39;API 키를 입력해야 합니다.&#39;,
      &#39;Google Cloud에서 발급한 YouTube Data API 키가 입력되지 않았습니다.&#39;,
      [&#39;API 키 입력란이 비어 있습니다.&#39;],
      [
        &#39;Google Cloud Console에서 API 키를 발급합니다.&#39;,
        &#39;API 키 전체를 사이드바 입력란에 붙여 넣습니다.&#39;,
        &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 입력&#39;
      }
    ));
  }

  if (cleanedKey.length &lt; 20) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_FORMAT_INVALID&#39;,
      &#39;API 키 형식을 확인해야 합니다.&#39;,
      &#39;입력한 문자열이 일반적인 Google API 키 길이보다 짧습니다.&#39;,
      [
        &#39;API 키 일부만 복사했을 수 있습니다.&#39;,
        &#39;프로젝트 ID나 다른 값을 입력했을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;키 유형이 ‘API 키’인지 확인합니다.&#39;,
        &#39;API 키 값을 처음부터 끝까지 다시 복사하여 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 다시 입력&#39;
      }
    ));
  }

  validateApiKey_(cleanedKey);

  const props = getUserProperties_();
  props.setProperty(YT_CFG.API_KEY_PROP, cleanedKey);

  // 이전 API 키 오류 안내가 새 키 저장 뒤 다시 나타나지 않도록 정리합니다.
  const previous = loadLastResult_();
  if (previous &amp;&amp; previous.status === &#39;ERROR&#39;) {
    props.deleteProperty(YT_CFG.LAST_RESULT_PROP);
  }

  return getSidebarState();
}


/**
 * 저장된 API 키를 삭제합니다.
 */
function deleteApiKey() {
  if (loadState_()) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_DELETE_BLOCKED&#39;,
      &#39;수집 중에는 API 키를 삭제할 수 없습니다.&#39;,
      &#39;현재 댓글 수집 작업이 저장된 API 키를 사용하고 있습니다.&#39;,
      [&#39;진행 중인 작업이 존재합니다.&#39;],
      [
        &#39;현재 수집을 완료하거나 취소합니다.&#39;,
        &#39;수집 상태가 종료된 뒤 API 키를 삭제합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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_(
        &#39;SPREADSHEET_NOT_FOUND&#39;,
        &#39;댓글을 저장할 스프레드시트를 찾지 못했습니다.&#39;,
        &#39;현재 Apps Script가 스프레드시트와 연결된 상태인지 확인해야 합니다.&#39;,
        [
          &#39;독립 실행형 Apps Script 프로젝트에서 코드를 실행했을 수 있습니다.&#39;,
          &#39;스프레드시트가 닫히거나 접근 권한이 변경되었을 수 있습니다.&#39;
        ],
        [
          &#39;댓글을 저장할 Google 스프레드시트를 엽니다.&#39;,
          &#39;‘확장 프로그램 → Apps Script’에서 이 코드를 실행합니다.&#39;,
          &#39;스프레드시트를 새로고침하고 사이드바를 다시 엽니다.&#39;
        ],
        { actionLabel: &#39;확인&#39; }
      ));
    }

    // 영상과 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: &#39;RUNNING&#39;,
      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: &#39;&#39;,
      threadDone: false,

      currentReplyParentId: &#39;&#39;,
      nextReplyPageToken: &#39;&#39;,

      topCount: 0,
      replyCount: 0,
      threadPages: 0,
      replyPages: 0,
      apiCalls: 1,

      startedAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      message: outputSheet.getName() + &#39;에서 수집을 시작했습니다.&#39;,
      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 &lt; 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 || &#39;&#39;;
        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 = &#39;실행 시간을 고려하여 진행 상태를 저장했습니다.&#39;;
    saveState_(state);
    scheduleContinuation_();

  } catch (error) {
    deleteContinuationTriggers_();

    const errorInfo = normalizeErrorInfo_(error, &#39;댓글 수집&#39;);

    if (errorInfo.code === &#39;API_KEY_INVALID&#39;) {
      getUserProperties_().deleteProperty(YT_CFG.API_KEY_PROP);
    }

    if (commentsSheet) {
      try {
        finalizeCommentsSheet_(commentsSheet);
      } catch (formatError) {
        console.error(formatError);
      }
    }

    if (state) {
      state.status = &#39;ERROR&#39;;
      state.updatedAt = new Date().toISOString();
      state.message = errorInfo.summary;
      state.error = errorInfo;
      saveLastResult_(state, &#39;ERROR&#39;, 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, &#39;true&#39;);
  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 &lt;= 0) {
    state.threadDone = true;
    state.nextThreadPageToken = &#39;&#39;;
    return;
  }

  const params = {
    part: state.includeReplies ? &#39;snippet,replies&#39; : &#39;snippet&#39;,
    videoId: state.videoId,
    maxResults: Math.min(100, remaining),
    textFormat: &#39;plainText&#39;
  };

  if (state.nextThreadPageToken) {
    params.pageToken = state.nextThreadPageToken;
  }

  const data = youtubeGet_(&#39;commentThreads&#39;, 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 &gt;= 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 = &#39;DONE&#39;;
    let nextReplyIndex = 1;

    commentRows.push(
      commentToOutputRow_(state, topComment, topNumber)
    );

    batchIds.add(topCommentId);
    topAdded += 1;

    const inlineReplies =
      thread.replies &amp;&amp; Array.isArray(thread.replies.comments)
        ? thread.replies.comments
        : [];

    if (state.includeReplies &amp;&amp; totalReplyCount &gt; 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 + &#39;_&#39; + nextReplyIndex;
          nextReplyIndex += 1;

          commentRows.push(
            commentToOutputRow_(state, reply, displayNumber)
          );

          internalRows.push(
            buildInternalRow_(
              replyId,
              topCommentId,
              displayNumber,
              &#39;reply&#39;,
              &#39;&#39;,
              &#39;&#39;,
              &#39;&#39;,
              &#39;&#39;
            )
          );

          batchIds.add(replyId);
          replyAdded += 1;
        });
      } else {
        // commentThreads.list의 replies에는 전체 답글이 보장되지 않으므로
        // comments.list로 전체 답글을 다시 가져옵니다.
        replyStatus = &#39;PENDING&#39;;
        nextReplyIndex = 1;
      }
    }

    internalRows.push(
      buildInternalRow_(
        topCommentId,
        &#39;&#39;,
        topNumber,
        &#39;topLevel&#39;,
        replyStatus,
        nextReplyIndex,
        totalReplyCount,
        &#39;&#39;
      )
    );
  });

  appendCollectedRecords_(
    commentsSheet,
    internalSheet,
    commentRows,
    internalRows
  );

  batchIds.forEach(function(id) {
    existingIds.add(id);
  });

  state.topCount += topAdded;
  state.replyCount += replyAdded;

  const reachedTarget = state.topCount &gt;= state.targetTopLevelCount;
  const hasNextPage = Boolean(data.nextPageToken);

  state.nextThreadPageToken =
    !reachedTarget &amp;&amp; hasNextPage
      ? data.nextPageToken
      : &#39;&#39;;

  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 = &#39;&#39;;
    state.nextReplyPageToken = &#39;&#39;;
    return;
  }

  const params = {
    part: &#39;snippet&#39;,
    parentId: state.currentReplyParentId,
    maxResults: 100,
    textFormat: &#39;plainText&#39;
  };

  const pageToken =
    state.nextReplyPageToken || topRecord.replyPageToken || &#39;&#39;;

  if (pageToken) {
    params.pageToken = pageToken;
  }

  const data = youtubeGet_(&#39;comments&#39;, 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) + &#39;_&#39; + nextReplyIndex;

    nextReplyIndex += 1;

    commentRows.push(
      commentToOutputRow_(state, reply, displayNumber)
    );

    internalRows.push(
      buildInternalRow_(
        replyId,
        state.currentReplyParentId,
        displayNumber,
        &#39;reply&#39;,
        &#39;&#39;,
        &#39;&#39;,
        &#39;&#39;,
        &#39;&#39;
      )
    );

    batchIds.add(replyId);
    replyAdded += 1;
  });

  appendCollectedRecords_(
    commentsSheet,
    internalSheet,
    commentRows,
    internalRows
  );

  batchIds.forEach(function(id) {
    existingIds.add(id);
  });

  state.replyCount += replyAdded;

  const nextPageToken = data.nextPageToken || &#39;&#39;;
  const nextStatus = nextPageToken ? &#39;IN_PROGRESS&#39; : &#39;DONE&#39;;

  updateTopReplyProgress_(
    internalSheet,
    topRecord.rowNumber,
    nextStatus,
    nextReplyIndex,
    nextPageToken
  );

  if (nextPageToken) {
    state.nextReplyPageToken = nextPageToken;
  } else {
    state.currentReplyParentId = &#39;&#39;;
    state.nextReplyPageToken = &#39;&#39;;
  }

  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(&#39;IN_PROGRESS&#39;)
    .matchEntireCell(true)
    .useRegularExpression(false)
    .findNext();

  if (!found) {
    found = statusRange
      .createTextFinder(&#39;PENDING&#39;)
      .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] === &#39;PENDING&#39;) {
    internalSheet
      .getRange(rowNumber, YT_CFG.INTERNAL_COL.REPLY_STATUS)
      .setValue(&#39;IN_PROGRESS&#39;);
  }

  return {
    rowNumber: rowNumber,
    commentId: String(values[YT_CFG.INTERNAL_COL.COMMENT_ID - 1] || &#39;&#39;),
    displayNumber: String(
      values[YT_CFG.INTERNAL_COL.DISPLAY_NUMBER - 1] || &#39;&#39;
    ),
    nextReplyIndex: Number(
      values[YT_CFG.INTERNAL_COL.NEXT_REPLY_INDEX - 1] || 1
    ),
    pageToken: String(
      values[YT_CFG.INTERNAL_COL.REPLY_PAGE_TOKEN - 1] || &#39;&#39;
    )
  };
}


/**
 * 댓글 1건을 사용자가 보는 8열 행으로 변환합니다.
 */
function commentToOutputRow_(state, comment, displayNumber) {
  const snippet = (comment &amp;&amp; comment.snippet) || {};
  const text = String(snippet.textDisplay || &#39;&#39;);

  return [
    String(displayNumber),
    safeCellText_(state.videoChannelTitle || &#39;&#39;),
    safeCellText_(state.videoTitle || &#39;&#39;),
    safeCellText_(snippet.authorDisplayName || &#39;&#39;),
    safeCellText_(text),
    Number(snippet.likeCount || 0),
    apiDateToSheetValue_(snippet.publishedAt),
    calculateCommentBytes_(text)
  ];
}


/**
 * 숨김 내부 시트의 행을 만듭니다.
 */
function buildInternalRow_(
  commentId,
  parentCommentId,
  displayNumber,
  commentType,
  replyStatus,
  nextReplyIndex,
  totalReplyCount,
  replyPageToken
) {
  return [
    safeCellText_(commentId || &#39;&#39;),
    safeCellText_(parentCommentId || &#39;&#39;),
    String(displayNumber || &#39;&#39;),
    String(commentType || &#39;&#39;),
    String(replyStatus || &#39;&#39;),
    nextReplyIndex === &#39;&#39; ? &#39;&#39; : Number(nextReplyIndex || 1),
    totalReplyCount === &#39;&#39; ? &#39;&#39; : Number(totalReplyCount || 0),
    safeCellText_(replyPageToken || &#39;&#39;)
  ];
}


/**
 * 댓글 행과 내부 관리 행을 각각 일괄 기록합니다.
 */
function appendCollectedRecords_(
  commentsSheet,
  internalSheet,
  commentRows,
  internalRows
) {
  if (commentRows.length !== internalRows.length) {
    throwStructuredError_(buildErrorInfo_(
      &#39;INTERNAL_ROW_MISMATCH&#39;,
      &#39;댓글 저장 구조를 확인해야 합니다.&#39;,
      &#39;댓글 행과 내부 관리 행의 개수가 일치하지 않습니다.&#39;,
      [&#39;수집 상태를 기록하는 내부 데이터가 일시적으로 어긋났습니다.&#39;],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;사이드바에서 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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 &lt;= 1) {
    return null;
  }

  const found = internalSheet
    .getRange(2, YT_CFG.INTERNAL_COL.COMMENT_ID, lastRow - 1, 1)
    .createTextFinder(String(commentId || &#39;&#39;))
    .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] || &#39;&#39;),
    displayNumber: String(
      values[YT_CFG.INTERNAL_COL.DISPLAY_NUMBER - 1] || &#39;&#39;
    ),
    replyStatus: String(
      values[YT_CFG.INTERNAL_COL.REPLY_STATUS - 1] || &#39;&#39;
    ),
    nextReplyIndex: Number(
      values[YT_CFG.INTERNAL_COL.NEXT_REPLY_INDEX - 1] || 1
    ),
    replyPageToken: String(
      values[YT_CFG.INTERNAL_COL.REPLY_PAGE_TOKEN - 1] || &#39;&#39;
    )
  };
}


/**
 * 최상위 댓글의 답글 수집 진행 정보를 갱신합니다.
 */
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 || &#39;&#39;
    ]]);
}


/**
 * 완료 처리
 */
function finishCollection_(ss, commentsSheet, state) {
  state.status = &#39;COMPLETED&#39;;
  state.updatedAt = new Date().toISOString();
  state.finishedAt = new Date().toISOString();
  state.message = state.topCount &lt; state.targetTopLevelCount
    ? &#39;댓글 &#39; +
      state.topCount +
      &#39;개와 대댓글 &#39; +
      state.replyCount +
      &#39;개를 수집했습니다.&#39;
    : &#39;댓글 수집을 완료했습니다.&#39;;
  state.error = null;

  finalizeCommentsSheet_(commentsSheet);
  saveLastResult_(state, &#39;COMPLETED&#39;, 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) === &#39;true&#39;;
}


/**
 * 취소 처리
 */
function finalizeCancellation_(ss, commentsSheet, state) {
  const message = &#39;사용자가 수집을 취소했습니다. 이미 저장된 댓글은 유지됩니다.&#39;;

  state.status = &#39;CANCELLED&#39;;
  state.updatedAt = new Date().toISOString();
  state.finishedAt = new Date().toISOString();
  state.message = message;
  state.error = null;

  finalizeCommentsSheet_(commentsSheet);
  saveLastResult_(state, &#39;CANCELLED&#39;, 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_(
    &#39;videos&#39;,
    {
      part: &#39;id&#39;,
      id: &#39;jNQXAC9IVRw&#39;
    },
    apiKey
  );
}


/**
 * 영상 메타데이터를 조회합니다.
 */
function getVideoMetadataWithKey_(videoId, apiKey) {
  const data = youtubeGetWithKey_(
    &#39;videos&#39;,
    {
      part: &#39;snippet,statistics&#39;,
      id: videoId
    },
    apiKey
  );

  if (!Array.isArray(data.items) || data.items.length === 0) {
    throwStructuredError_(buildErrorInfo_(
      &#39;VIDEO_NOT_FOUND&#39;,
      &#39;영상 정보를 찾을 수 없습니다.&#39;,
      &#39;입력한 영상 주소에 해당하는 공개 영상을 YouTube Data API에서 조회하지 못했습니다.&#39;,
      [
        &#39;영상 주소나 11자리 영상 ID가 정확하지 않을 수 있습니다.&#39;,
        &#39;영상이 삭제되었거나 비공개 상태일 수 있습니다.&#39;,
        &#39;연령·지역·계정 권한에 따라 접근이 제한된 영상일 수 있습니다.&#39;
      ],
      [
        &#39;브라우저에서 해당 영상 주소가 정상적으로 열리는지 확인합니다.&#39;,
        &#39;주소 표시줄의 영상 URL 전체를 다시 복사합니다.&#39;,
        &#39;사이드바의 영상 주소를 교체한 뒤 수집을 다시 시작합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: { videoId: videoId }
      }
    ));
  }

  const item = data.items[0];
  const snippet = item.snippet || {};
  const statistics = item.statistics || {};

  return {
    title: snippet.title || &#39;&#39;,
    channelId: snippet.channelId || &#39;&#39;,
    channelTitle: snippet.channelTitle || &#39;&#39;,
    commentCount: statistics.commentCount || &#39;&#39;
  };
}


/**
 * 저장된 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 &amp;&amp; value !== null &amp;&amp; value !== &#39;&#39;;
    })
    .map(function(key) {
      return encodeURIComponent(key) + &#39;=&#39; + encodeURIComponent(queryParams[key]);
    })
    .join(&#39;&amp;&#39;);

  const url = YT_CFG.API_BASE + &#39;/&#39; + resource + &#39;?&#39; + query;
  const transientCodes = [429, 500, 502, 503, 504];

  for (let attempt = 0; attempt &lt; 3; attempt += 1) {
    let response;

    try {
      response = UrlFetchApp.fetch(url, {
        method: &#39;get&#39;,
        muteHttpExceptions: true,
        headers: { Accept: &#39;application/json&#39; }
      });
    } catch (networkError) {
      if (attempt &lt; 2) {
        Utilities.sleep(Math.pow(2, attempt) * 1000);
        continue;
      }

      throwStructuredError_(buildErrorInfo_(
        &#39;NETWORK_REQUEST_FAILED&#39;,
        &#39;YouTube 서버에 연결하지 못했습니다.&#39;,
        &#39;Apps Script에서 YouTube Data API로 보내는 외부 요청이 완료되지 않았습니다.&#39;,
        [
          &#39;Google 서비스 또는 네트워크에 일시적인 장애가 발생했을 수 있습니다.&#39;,
          &#39;Apps Script의 외부 요청 권한이 승인되지 않았을 수 있습니다.&#39;
        ],
        [
          &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
          &#39;Apps Script 권한 승인 화면이 나타나면 요청된 권한을 허용합니다.&#39;,
          &#39;Apps Script의 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;
        ],
        {
          actionLabel: &#39;확인&#39;,
          technical: {
            resource: resource,
            originalMessage: String(networkError &amp;&amp; networkError.message || networkError)
          }
        }
      ));
    }

    const statusCode = response.getResponseCode();
    const responseText = response.getContentText();

    if (statusCode &gt;= 200 &amp;&amp; statusCode &lt; 300) {
      return responseText ? JSON.parse(responseText) : {};
    }

    if (transientCodes.indexOf(statusCode) &gt;= 0 &amp;&amp; attempt &lt; 2) {
      Utilities.sleep(Math.pow(2, attempt) * 1000);
      continue;
    }

    const parsedError = parseYouTubeErrorResponse_(responseText);

    throwStructuredError_(classifyYouTubeApiError_(
      statusCode,
      parsedError.reason,
      parsedError.message,
      resource
    ));
  }

  throwStructuredError_(buildErrorInfo_(
    &#39;YOUTUBE_REQUEST_REPEATED_FAILURE&#39;,
    &#39;YouTube API 요청이 반복적으로 실패했습니다.&#39;,
    &#39;일시적인 서버 장애가 재시도 후에도 계속되었습니다.&#39;,
    [&#39;YouTube 또는 Google 네트워크 서비스가 불안정할 수 있습니다.&#39;],
    [
      &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
      &#39;반복되면 Apps Script의 ‘실행’ 기록을 확인합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: { resource: resource } }
  ));
}


function parseYouTubeErrorResponse_(responseText) {
  let reason = &#39;&#39;;
  let message = String(responseText || &#39;&#39;);

  try {
    const parsed = JSON.parse(responseText);

    if (parsed.error) {
      message = parsed.error.message || message;

      if (Array.isArray(parsed.error.errors) &amp;&amp; parsed.error.errors[0]) {
        reason = parsed.error.errors[0].reason || &#39;&#39;;
      }

      if (!reason &amp;&amp; parsed.error.status) {
        reason = parsed.error.status;
      }
    }
  } catch (parseError) {
    // JSON이 아닌 오류 본문은 문자열 그대로 분류합니다.
  }

  return {
    reason: String(reason || &#39;&#39;),
    message: String(message || &#39;&#39;)
  };
}


/**
 * YouTube API 오류를 한국어 모달용 정보로 분류합니다.
 */
function classifyYouTubeApiError_(statusCode, reason, message, resource) {
  const reasonText = String(reason || &#39;&#39;);
  const messageText = String(message || &#39;&#39;);
  const combined = (reasonText + &#39; &#39; + messageText).toLowerCase();
  const technical = {
    httpStatus: statusCode,
    apiReason: reasonText,
    resource: resource,
    originalMessage: messageText
  };

  if (
    combined.indexOf(&#39;keyinvalid&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api key not valid&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api_key_invalid&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_INVALID&#39;,
      &#39;API 키를 사용할 수 없습니다.&#39;,
      &#39;입력한 API 키가 정확하지 않거나 삭제·재발급되어 현재 요청에 사용할 수 없습니다.&#39;,
      [
        &#39;API 키 일부가 누락되었을 수 있습니다.&#39;,
        &#39;Google Cloud에서 기존 키가 삭제되거나 재발급되었을 수 있습니다.&#39;,
        &#39;API 키가 아닌 다른 프로젝트 값을 입력했을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;키 유형이 ‘API 키’인지 확인합니다.&#39;,
        &#39;API 키 전체를 다시 복사합니다.&#39;,
        &#39;사이드바에서 API 키를 다시 입력하고 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;accessnotconfigured&#39;) &gt;= 0 ||
    combined.indexOf(&#39;access not configured&#39;) &gt;= 0 ||
    combined.indexOf(&#39;service_disabled&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api_disabled&#39;) &gt;= 0 ||
    combined.indexOf(&#39;has not been used in project&#39;) &gt;= 0 ||
    combined.indexOf(&#39;it is disabled&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_API_NOT_ENABLED&#39;,
      &#39;YouTube Data API v3가 활성화되어 있지 않습니다.&#39;,
      &#39;API 키가 속한 Google Cloud 프로젝트에서 YouTube Data API v3 사용 설정을 켜야 합니다.&#39;,
      [
        &#39;API 키를 만든 프로젝트에서 YouTube Data API v3를 아직 활성화하지 않았습니다.&#39;,
        &#39;API를 활성화한 프로젝트와 API 키를 발급한 프로젝트가 다를 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console에서 API 키를 만든 프로젝트를 선택합니다.&#39;,
        &#39;‘API 및 서비스 → 라이브러리’로 이동합니다.&#39;,
        &#39;‘YouTube Data API v3’를 검색하고 ‘사용’을 누릅니다.&#39;,
        &#39;설정 반영을 위해 1~5분 기다린 뒤 API 키를 다시 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;YouTube Data API 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/library/youtube.googleapis.com&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;iprefererblocked&#39;) &gt;= 0 ||
    combined.indexOf(&#39;referer&#39;) &gt;= 0 &amp;&amp; combined.indexOf(&#39;blocked&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api key restrictions&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_APPLICATION_RESTRICTION&#39;,
      &#39;API 키의 애플리케이션 제한이 Apps Script 요청을 차단했습니다.&#39;,
      &#39;현재 키에 설정된 웹사이트·IP 제한이 Apps Script의 UrlFetch 요청 방식과 맞지 않습니다.&#39;,
      [
        &#39;HTTP 리퍼러 제한 또는 특정 IP 주소 제한이 적용되어 있을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;사용 중인 API 키의 애플리케이션 제한을 확인합니다.&#39;,
        &#39;Apps Script에서 사용할 수 있는 제한 방식으로 조정합니다.&#39;,
        &#39;API 제한은 ‘YouTube Data API v3’로 유지합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 제한 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;accessnotconfigured&#39;) &lt; 0 &amp;&amp;
    (
      combined.indexOf(&#39;forbidden&#39;) &gt;= 0 &amp;&amp;
      combined.indexOf(&#39;api&#39;) &gt;= 0 &amp;&amp;
      combined.indexOf(&#39;key&#39;) &gt;= 0
    )
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_API_RESTRICTION&#39;,
      &#39;API 키의 API 제한 설정을 확인해야 합니다.&#39;,
      &#39;현재 API 키가 YouTube Data API v3 호출을 허용하지 않을 수 있습니다.&#39;,
      [
        &#39;API 키의 ‘API 제한’ 목록에서 YouTube Data API v3가 빠져 있을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;사용 중인 API 키를 선택합니다.&#39;,
        &#39;‘API 제한’에서 YouTube Data API v3를 허용합니다.&#39;,
        &#39;저장 후 1~5분 뒤 다시 실행합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 제한 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;quotaexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;dailylimitexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;daily limit&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_QUOTA_EXCEEDED&#39;,
      &#39;YouTube API의 오늘 할당량을 모두 사용했습니다.&#39;,
      &#39;현재 Google Cloud 프로젝트에서 오늘 사용할 수 있는 YouTube Data API 할당량이 남아 있지 않습니다.&#39;,
      [&#39;같은 프로젝트의 다른 프로그램에서도 YouTube API 할당량을 사용했을 수 있습니다.&#39;],
      [
        &#39;Google Cloud Console에서 YouTube Data API 사용량을 확인합니다.&#39;,
        &#39;일일 할당량이 갱신된 뒤 다시 실행합니다.&#39;,
        &#39;필요하면 다음 수집의 댓글 개수를 줄입니다.&#39;
      ],
      {
        helpLabel: &#39;YouTube API 할당량 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas&#39;,
        actionLabel: &#39;확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;ratelimitexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;userratelimitexceeded&#39;) &gt;= 0 ||
    statusCode === 429
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_RATE_LIMIT&#39;,
      &#39;짧은 시간에 API 요청이 많이 발생했습니다.&#39;,
      &#39;YouTube Data API가 일시적으로 요청 속도를 제한했습니다.&#39;,
      [&#39;여러 수집 작업을 연속으로 실행했을 수 있습니다.&#39;],
      [
        &#39;현재 작업이 자동 재개되는지 확인합니다.&#39;,
        &#39;몇 분 뒤 다시 실행합니다.&#39;,
        &#39;동시에 여러 스프레드시트에서 같은 API 키를 사용하고 있다면 실행 횟수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (combined.indexOf(&#39;commentsdisabled&#39;) &gt;= 0) {
    return buildErrorInfo_(
      &#39;COMMENTS_DISABLED&#39;,
      &#39;이 영상에서는 댓글을 수집할 수 없습니다.&#39;,
      &#39;영상 게시자가 댓글 기능을 사용하지 않도록 설정했습니다.&#39;,
      [&#39;영상의 댓글 기능이 꺼져 있거나 YouTube 정책에 따라 제한되었습니다.&#39;],
      [
        &#39;영상 페이지에서 댓글 영역이 표시되는지 확인합니다.&#39;,
        &#39;댓글이 허용된 다른 영상 주소를 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;videonotfound&#39;) &gt;= 0 ||
    combined.indexOf(&#39;video not found&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;VIDEO_NOT_FOUND&#39;,
      &#39;영상 정보를 찾을 수 없습니다.&#39;,
      &#39;영상 주소가 잘못되었거나 영상이 삭제·비공개 상태일 수 있습니다.&#39;,
      [
        &#39;영상 ID가 정확하지 않을 수 있습니다.&#39;,
        &#39;영상 공개 상태가 변경되었을 수 있습니다.&#39;
      ],
      [
        &#39;브라우저에서 영상 주소가 정상적으로 열리는지 확인합니다.&#39;,
        &#39;영상 URL 전체를 다시 복사해 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (statusCode &gt;= 500) {
    return buildErrorInfo_(
      &#39;YOUTUBE_SERVER_ERROR&#39;,
      &#39;YouTube 서버에서 일시적인 오류가 발생했습니다.&#39;,
      &#39;YouTube Data API 서버가 현재 요청을 정상적으로 처리하지 못했습니다.&#39;,
      [&#39;YouTube 측의 일시적인 서비스 장애일 수 있습니다.&#39;],
      [
        &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
        &#39;진행 중인 수집이라면 자동 재개 여부를 확인합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (statusCode === 403) {
    return buildErrorInfo_(
      &#39;YOUTUBE_ACCESS_FORBIDDEN&#39;,
      &#39;YouTube 데이터에 접근할 수 없습니다.&#39;,
      &#39;API 키 설정, 영상 공개 상태 또는 Google Cloud 프로젝트 권한을 확인해야 합니다.&#39;,
      [
        &#39;API 키 제한 설정이 요청을 차단했을 수 있습니다.&#39;,
        &#39;영상이 공개 API에서 접근할 수 없는 상태일 수 있습니다.&#39;
      ],
      [
        &#39;API 키의 API 제한과 애플리케이션 제한을 확인합니다.&#39;,
        &#39;영상이 공개 상태인지 확인합니다.&#39;,
        &#39;Google Cloud에서 YouTube Data API v3가 활성화되어 있는지 확인합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (statusCode === 400) {
    return buildErrorInfo_(
      &#39;YOUTUBE_BAD_REQUEST&#39;,
      &#39;YouTube API 요청 형식을 확인해야 합니다.&#39;,
      &#39;영상 주소 또는 요청 매개변수 중 하나를 YouTube API가 처리하지 못했습니다.&#39;,
      [&#39;영상 주소가 올바르지 않거나 API 요청 값이 허용 범위를 벗어났을 수 있습니다.&#39;],
      [
        &#39;영상 주소를 다시 확인합니다.&#39;,
        &#39;수집 개수와 정렬 기준을 확인합니다.&#39;,
        &#39;같은 문제가 계속되면 새 수집으로 다시 시작합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;입력값 확인&#39;,
        technical: technical
      }
    );
  }

  return buildErrorInfo_(
    &#39;YOUTUBE_API_ERROR&#39;,
    &#39;YouTube API 요청을 완료하지 못했습니다.&#39;,
    &#39;YouTube Data API가 요청을 거부하거나 처리하지 못했습니다.&#39;,
    [&#39;API 키, 영상 공개 상태, 할당량 또는 네트워크 상태를 확인해야 합니다.&#39;],
    [
      &#39;API 키 설정과 YouTube Data API 활성화 여부를 확인합니다.&#39;,
      &#39;영상 주소가 공개 영상인지 확인합니다.&#39;,
      &#39;잠시 뒤 다시 실행합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: technical }
  );
}


/**
 * 영상 URL 또는 11자리 영상 ID에서 videoId를 추출합니다.
 */
function extractVideoId_(input) {
  const text = String(input || &#39;&#39;).trim();

  if (/^[A-Za-z0-9_-]{11}$/.test(text)) {
    return text;
  }

  const patterns = [
    /[?&amp;]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 &lt; patterns.length; i += 1) {
    const match = text.match(patterns[i]);

    if (match) {
      return match[1];
    }
  }

  throwStructuredError_(buildErrorInfo_(
    &#39;INVALID_VIDEO_URL&#39;,
    &#39;YouTube 영상 주소를 확인해야 합니다.&#39;,
    &#39;입력한 값에서 11자리 YouTube 영상 ID를 찾지 못했습니다.&#39;,
    [
      &#39;영상 주소 일부가 빠졌을 수 있습니다.&#39;,
      &#39;채널·재생목록 주소를 입력했을 수 있습니다.&#39;
    ],
    [
      &#39;YouTube에서 수집할 영상을 엽니다.&#39;,
      &#39;브라우저 주소 표시줄의 영상 URL 전체를 복사합니다.&#39;,
      &#39;사이드바의 영상 주소 입력란에 붙여 넣습니다.&#39;
    ],
    {
      focusTarget: &#39;videoUrl&#39;,
      actionLabel: &#39;영상 주소 다시 입력&#39;
    }
  ));
}


/**
 * 사이드바 입력값을 검증합니다.
 */
function normalizeSidebarInput_(formData) {
  const form = formData || {};
  const videoUrl = String(form.videoUrl || &#39;&#39;).trim();
  const limit = parseInt(form.limit, 10);

  if (!videoUrl) {
    throwStructuredError_(buildErrorInfo_(
      &#39;VIDEO_URL_REQUIRED&#39;,
      &#39;YouTube 영상 주소를 입력해야 합니다.&#39;,
      &#39;수집 대상 영상 주소가 비어 있습니다.&#39;,
      [&#39;영상 주소 입력란에 값이 없습니다.&#39;],
      [&#39;수집할 YouTube 영상의 URL을 입력합니다.&#39;],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 입력&#39;
      }
    ));
  }

  if (!Number.isInteger(limit)) {
    throwStructuredError_(buildErrorInfo_(
      &#39;COMMENT_LIMIT_INVALID&#39;,
      &#39;수집 개수를 정수로 입력해야 합니다.&#39;,
      &#39;수집할 최상위 댓글 개수가 올바른 정수 형식이 아닙니다.&#39;,
      [&#39;빈 값, 소수 또는 문자가 입력되었을 수 있습니다.&#39;],
      [&#39;1 이상의 정수를 입력합니다.&#39;],
      {
        focusTarget: &#39;limit&#39;,
        actionLabel: &#39;수집 개수 입력&#39;
      }
    ));
  }

  if (limit &lt; YT_CFG.MIN_LIMIT || limit &gt; YT_CFG.MAX_LIMIT) {
    throwStructuredError_(buildErrorInfo_(
      &#39;COMMENT_LIMIT_OUT_OF_RANGE&#39;,
      &#39;수집 개수가 허용 범위를 벗어났습니다.&#39;,
      &#39;수집 개수는 &#39; + YT_CFG.MIN_LIMIT + &#39;개 이상 &#39; +
        YT_CFG.MAX_LIMIT + &#39;개 이하로 입력해야 합니다.&#39;,
      [&#39;입력한 개수가 코드에 설정된 운영 범위를 벗어났습니다.&#39;],
      [
        YT_CFG.MIN_LIMIT + &#39;~&#39; + YT_CFG.MAX_LIMIT +
          &#39; 사이의 정수를 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;limit&#39;,
        actionLabel: &#39;수집 개수 수정&#39;
      }
    ));
  }

  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 ... 순서로 만듭니다.
 *
 * 새 스프레드시트에 기본 빈 &#39;시트1&#39; 하나만 존재하는 경우에는 그 시트를
 * 첫 결과 시트로 사용합니다. 그 이후 수집부터는 항상 새 시트를 추가합니다.
 */
function createNextOutputSheet_(ss) {
  const sheets = ss.getSheets();

  if (
    sheets.length === 1 &amp;&amp;
    sheets[0].getName() === YT_CFG.OUTPUT_SHEET_PREFIX + &#39;1&#39; &amp;&amp;
    isSheetCompletelyBlank_(sheets[0])
  ) {
    prepareOutputSheet_(sheets[0]);
    return sheets[0];
  }

  let maxNumber = 0;
  const namePattern = new RegExp(
    &#39;^&#39; + escapeRegExp_(YT_CFG.OUTPUT_SHEET_PREFIX) + &#39;(\\d+)$&#39;
  );

  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 &amp;&amp; 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(&#39;A:A&#39;).setNumberFormat(&#39;@&#39;);
  formatCommentsSheet_(sheet);
}


/**
 * 새 수집을 위해 숨김 내부 시트만 초기화합니다.
 * 이전 결과 시트의 내용은 변경하지 않습니다.
 */
function resetInternalData_(ss) {
  const internal = ss.getSheetByName(YT_CFG.INTERNAL_SHEET);

  if (!internal) {
    throwStructuredError_(buildErrorInfo_(
      &#39;INTERNAL_SHEET_NOT_FOUND&#39;,
      &#39;내부 관리 시트를 준비하지 못했습니다.&#39;,
      &#39;댓글 수집 상태를 저장하는 숨김 시트가 존재하지 않습니다.&#39;,
      [&#39;내부 시트 생성 과정이 완료되지 않았습니다.&#39;],
      [&#39;사이드바를 닫았다가 다시 열고 새 수집을 시작합니다.&#39;],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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 &gt; 0) {
    outputSheet = ss.getSheets().find(function(sheet) {
      return sheet.getSheetId() === expectedId;
    }) || null;
  }

  if (!outputSheet &amp;&amp; state.outputSheetName) {
    outputSheet = ss.getSheetByName(String(state.outputSheetName));
  }

  if (!outputSheet) {
    throwStructuredError_(buildErrorInfo_(
      &#39;OUTPUT_SHEET_NOT_FOUND&#39;,
      &#39;수집 결과 시트를 찾을 수 없습니다.&#39;,
      &#39;현재 수집 작업과 연결된 결과 시트가 삭제되었거나 이름과 식별 정보가 변경되었습니다.&#39;,
      [
        &#39;수집 중 결과 시트를 삭제했을 수 있습니다.&#39;,
        &#39;스프레드시트 구조가 외부 작업으로 변경되었을 수 있습니다.&#39;
      ],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;사이드바에서 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;새 수집 필요&#39; }
    ));
  }

  return outputSheet;
}


function escapeRegExp_(text) {
  return String(text).replace(/[.*+?^${}()|[\]\\]/g, &#39;\\$&amp;&#39;);
}


/**
 * 이전 버전이 만든 수집로그 시트를 제거합니다.
 */
function deleteLegacyLogSheet_(ss) {
  const logSheet = ss.getSheetByName(YT_CFG.LEGACY_LOG_SHEET);

  if (logSheet &amp;&amp; ss.getSheets().length &gt; 1) {
    ss.deleteSheet(logSheet);
  }
}


/**
 * 댓글 시트를 번호 순으로 정렬하고 최종 서식을 적용합니다.
 */
function finalizeCommentsSheet_(sheet) {
  sortCommentsByDisplayNumber_(sheet);
  formatCommentsSheet_(sheet);
}


function sortCommentsByDisplayNumber_(sheet) {
  const lastRow = sheet.getLastRow();

  if (lastRow &lt;= 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] || &#39;&#39;).split(&#39;_&#39;);
    return [
      Number(parts[0] || 0),
      parts.length &gt; 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(&#39;bold&#39;)
    .setWrap(true)
    .setVerticalAlignment(&#39;middle&#39;);

  sheet.getRange(&#39;A:A&#39;).setNumberFormat(&#39;@&#39;);

  if (lastRow &gt; 1) {
    sheet
      .getRange(2, 7, lastRow - 1, 1)
      .setNumberFormat(&#39;yyyy-mm-dd hh:mm:ss&#39;);

    sheet
      .getRange(2, 5, lastRow - 1, 1)
      .setWrap(true)
      .setVerticalAlignment(&#39;top&#39;);

    sheet
      .getRange(2, 1, lastRow - 1, YT_CFG.HEADERS.length)
      .setVerticalAlignment(&#39;top&#39;);

    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 &lt;= 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 &lt;= 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] === &#39;topLevel&#39;) {
      counts.top += 1;
    } else if (row[0] === &#39;reply&#39;) {
      counts.reply += 1;
    }
  });

  return counts;
}


/**
 * 댓글 글자수를 지정된 바이트 규칙으로 계산합니다.
 * - ASCII 영역(영문, 숫자, 공백, 일반 문장부호): 1바이트
 * - 한글을 포함한 비ASCII 유니코드 문자: 코드 포인트당 2바이트
 */
function calculateCommentBytes_(value) {
  const text = String(value || &#39;&#39;);
  let bytes = 0;

  Array.from(text).forEach(function(character) {
    const codePoint = character.codePointAt(0);
    bytes += codePoint &lt;= 0x7F ? 1 : 2;
  });

  return bytes;
}


/**
 * 외부 문자열이 시트 수식으로 실행되는 것을 방지합니다.
 */
function safeCellText_(value) {
  const text = value === undefined || value === null ? &#39;&#39; : String(value);
  return /^[=+\-@]/.test(text) ? &quot;&#39;&quot; + text : text;
}


function apiDateToSheetValue_(value) {
  return value ? new Date(value) : &#39;&#39;;
}


function ensureColumnCount_(sheet, requiredColumns) {
  const current = sheet.getMaxColumns();

  if (current &lt; requiredColumns) {
    sheet.insertColumnsAfter(current, requiredColumns - current);
  }
}


function ensureExactColumnCount_(sheet, requiredColumns) {
  ensureColumnCount_(sheet, requiredColumns);

  const current = sheet.getMaxColumns();

  if (current &gt; 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_(
      &#39;API_KEY_REQUIRED&#39;,
      &#39;API 키 설정이 필요합니다.&#39;,
      &#39;댓글 수집을 시작하기 전에 YouTube Data API 키를 저장해야 합니다.&#39;,
      [&#39;현재 사용자 계정에 저장된 API 키가 없습니다.&#39;],
      [
        &#39;사이드바 상단의 API 키 입력란에 키를 입력합니다.&#39;,
        &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
      ],
      {
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 입력&#39;
      }
    ));
  }

  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 &gt; 8500) {
    throwStructuredError_(buildErrorInfo_(
      &#39;STATE_STORAGE_LIMIT&#39;,
      &#39;수집 진행 상태를 저장할 수 없습니다.&#39;,
      &#39;Apps Script 사용자 속성의 안전 저장 크기를 초과했습니다.&#39;,
      [&#39;진행 상태 데이터가 예상보다 커졌습니다.&#39;],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;댓글 수집 개수를 줄여 새로 실행합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, 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 || &#39;&#39;,
    videoId: state.videoId || &#39;&#39;,
    videoTitle: state.videoTitle || &#39;&#39;,
    targetTopLevelCount: Number(state.targetTopLevelCount || 0),
    topCount: Number(state.topCount || 0),
    replyCount: Number(state.replyCount || 0),
    apiCalls: Number(state.apiCalls || 0),
    startedAt: state.startedAt || &#39;&#39;,
    updatedAt: new Date().toISOString(),
    finishedAt: new Date().toISOString(),
    message: message || &#39;&#39;,
    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_(
      &#39;STATE_VERSION_MISMATCH&#39;,
      &#39;이전 코드에서 시작된 수집 작업을 이어갈 수 없습니다.&#39;,
      &#39;현재 Code.gs의 댓글 저장 구조가 이전 실행 상태와 다릅니다.&#39;,
      [&#39;고정 결과 시트 또는 정렬 기준을 사용하던 이전 코드의 작업 상태가 남아 있습니다.&#39;],
      [
        &#39;사이드바에서 진행 중인 수집을 취소합니다.&#39;,
        &#39;현재 코드로 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;새 수집 필요&#39; }
    ));
  }
}


function normalizeState_(state) {
  state.currentReplyParentId = state.currentReplyParentId || &#39;&#39;;
  state.nextReplyPageToken = state.nextReplyPageToken || &#39;&#39;;
  state.nextThreadPageToken = state.nextThreadPageToken || &#39;&#39;;
  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 || &#39;&#39;);
  state.error = state.error || null;
}


function maskApiKey_(apiKey) {
  const key = String(apiKey || &#39;&#39;);
  return &#39;••••••••••••&#39; + 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 &gt; 0
    ? Math.min(100, Math.round((top / target) * 100))
    : 0;

  let stage = &#39;대기 중&#39;;

  if (status === &#39;RUNNING&#39;) {
    if (state.currentReplyParentId) {
      stage = &#39;답글 수집 중&#39;;
    } else if (!state.threadDone) {
      stage = &#39;최상위 댓글 수집 중&#39;;
    } else {
      stage = &#39;마무리 중&#39;;
    }
  } else if (status === &#39;COMPLETED&#39;) {
    stage = &#39;완료&#39;;
  } else if (status === &#39;ERROR&#39;) {
    stage = &#39;오류&#39;;
  } else if (status === &#39;CANCELLED&#39;) {
    stage = &#39;취소됨&#39;;
  }

  return {
    status: status,
    stage: stage,
    outputSheetId: Number(state.outputSheetId || 0),
    outputSheetName: state.outputSheetName || &#39;&#39;,
    videoId: state.videoId || &#39;&#39;,
    videoTitle: state.videoTitle || &#39;&#39;,
    targetTopLevelCount: target,
    topCount: top,
    replyCount: reply,
    totalRows: top + reply,
    apiCalls: Number(state.apiCalls || 0),
    percent: percent,
    startedAt: state.startedAt || &#39;&#39;,
    updatedAt: state.updatedAt || &#39;&#39;,
    message: state.message || &#39;&#39;,
    error: state.error || null
  };
}


function buildServerProgressMessage_(state) {
  return (
    &#39;최상위 댓글 &#39; +
    state.topCount +
    &#39;/&#39; +
    state.targetTopLevelCount +
    &#39;개, 답글 &#39; +
    state.replyCount +
    &#39;개를 저장했습니다.&#39;
  );
}


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 || &#39;UNKNOWN_ERROR&#39;),
    title: String(title || &#39;작업을 완료하지 못했습니다.&#39;),
    summary: String(summary || &#39;처리 중 문제가 발생했습니다.&#39;),
    causes: Array.isArray(causes) ? causes.map(String) : [],
    steps: Array.isArray(steps) ? steps.map(String) : [],
    helpLabel: String(opts.helpLabel || &#39;&#39;),
    helpUrl: String(opts.helpUrl || &#39;&#39;),
    focusTarget: String(opts.focusTarget || &#39;&#39;),
    actionLabel: String(opts.actionLabel || &#39;확인&#39;),
    technical:
      opts.technical &amp;&amp; typeof opts.technical === &#39;object&#39;
        ? 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 || &#39;&#39;);
  const markerIndex = raw.indexOf(YT_CFG.STRUCTURED_ERROR_PREFIX);

  if (markerIndex &lt; 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 &amp;&amp; error.message
    ? String(error.message)
    : String(error || &#39;&#39;);

  const structured = extractStructuredError_(raw);

  if (structured) {
    return structured;
  }

  const lower = raw.toLowerCase();
  const technical = {
    context: String(context || &#39;작업 실행&#39;),
    originalMessage: raw
  };

  if (
    lower.indexOf(&#39;authorization is required&#39;) &gt;= 0 ||
    lower.indexOf(&#39;you do not have permission&#39;) &gt;= 0 ||
    lower.indexOf(&#39;permissions&#39;) &gt;= 0 &amp;&amp; lower.indexOf(&#39;required&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;,
      &#39;Google 권한 승인이 필요합니다.&#39;,
      &#39;현재 계정이 스프레드시트 또는 외부 API 요청 권한을 아직 승인하지 않았습니다.&#39;,
      [&#39;Apps Script를 처음 실행했거나 새로운 권한이 추가되었습니다.&#39;],
      [
        &#39;스프레드시트 메뉴에서 사이드바를 다시 엽니다.&#39;,
        &#39;권한 검토 화면에서 현재 Google 계정을 선택합니다.&#39;,
        &#39;요청되는 권한을 허용한 뒤 다시 실행합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (
    lower.indexOf(&#39;maximum execution time&#39;) &gt;= 0 ||
    lower.indexOf(&#39;exceeded maximum execution time&#39;) &gt;= 0 ||
    lower.indexOf(&#39;timed out&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;,
      &#39;한 번의 실행 가능 시간을 초과했습니다.&#39;,
      &#39;댓글 또는 답글 처리 시간이 길어 현재 실행 구간이 종료되었습니다.&#39;,
      [&#39;대량 댓글 수집으로 한 번의 Apps Script 실행 시간이 길어졌습니다.&#39;],
      [
        &#39;사이드바를 다시 열어 진행 상태를 확인합니다.&#39;,
        &#39;멈춘 경우 ‘중단 지점부터 실행’을 한 번 실행합니다.&#39;,
        &#39;같은 문제가 반복되면 수집 개수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (
    lower.indexOf(&#39;service invoked too many times&#39;) &gt;= 0 ||
    lower.indexOf(&#39;quota&#39;) &gt;= 0 &amp;&amp; lower.indexOf(&#39;apps script&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_SERVICE_QUOTA&#39;,
      &#39;Apps Script 서비스 사용 한도를 초과했습니다.&#39;,
      &#39;현재 Google 계정 또는 스크립트의 일일 서비스 사용량이 허용 범위를 넘었습니다.&#39;,
      [&#39;짧은 시간에 많은 외부 요청이나 스프레드시트 쓰기 작업이 실행되었습니다.&#39;],
      [
        &#39;잠시 뒤 다시 실행합니다.&#39;,
        &#39;같은 날 반복되면 다음 날 다시 실행합니다.&#39;,
        &#39;한 번에 수집하는 댓글 개수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  return buildErrorInfo_(
    &#39;UNEXPECTED_ERROR&#39;,
    &#39;예상하지 못한 오류가 발생했습니다.&#39;,
    &#39;오류 유형을 자동으로 판별하지 못했습니다.&#39;,
    [
      &#39;네트워크, 권한, Google 서비스 상태 또는 실행 환경에서 문제가 발생했을 수 있습니다.&#39;
    ],
    [
      &#39;사이드바를 닫았다가 다시 열고 같은 작업을 한 번 더 실행합니다.&#39;,
      &#39;Apps Script 편집기의 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;,
      &#39;API 키와 영상 주소를 다시 확인합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: technical }
  );
}
</pre></section><section><h3>Sidebar.html</h3><pre>&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;meta charset=&quot;utf-8&quot;&gt;
    &lt;style&gt;
      :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, &#39;Noto Sans KR&#39;, 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=&#39;text&#39;],
      input[type=&#39;password&#39;],
      input[type=&#39;number&#39;] {
        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);
        }
      }
    &lt;/style&gt;
  &lt;/head&gt;

  &lt;body&gt;
    &lt;h1&gt;YouTube 댓글 수집기&lt;/h1&gt;
    &lt;p class=&quot;subtitle&quot;&gt;YouTube Data API v3 → Google Sheets&lt;/p&gt;

    &lt;div id=&quot;message&quot; class=&quot;message&quot;&gt;&lt;/div&gt;

    &lt;section class=&quot;card&quot;&gt;
      &lt;h2&gt;1. API 키&lt;/h2&gt;

      &lt;div id=&quot;apiMissingView&quot;&gt;
        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;apiKey&quot;&gt;YouTube Data API 키&lt;/label&gt;
          &lt;input
            id=&quot;apiKey&quot;
            type=&quot;password&quot;
            autocomplete=&quot;off&quot;
            placeholder=&quot;AIza...&quot;
          &gt;
          &lt;div class=&quot;help&quot;&gt;
            최초 1회 저장합니다. 키는 사용자별 Apps Script 속성에 보관됩니다.
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;button id=&quot;saveApiButton&quot; class=&quot;primary&quot; type=&quot;button&quot;&gt;
          API 키 확인 후 저장
        &lt;/button&gt;
      &lt;/div&gt;

      &lt;div id=&quot;apiSavedView&quot; class=&quot;hidden&quot;&gt;
        &lt;div class=&quot;row between&quot;&gt;
          &lt;div class=&quot;status-line&quot;&gt;
            &lt;span class=&quot;badge success&quot;&gt;설정 완료&lt;/span&gt;
            &lt;span id=&quot;apiKeyMask&quot; class=&quot;masked-key&quot;&gt;&lt;/span&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;div class=&quot;row&quot; style=&quot;margin-top: 10px;&quot;&gt;
          &lt;button id=&quot;changeApiButton&quot; class=&quot;secondary small-button&quot; type=&quot;button&quot;&gt;
            키 변경
          &lt;/button&gt;
          &lt;button id=&quot;deleteApiButton&quot; class=&quot;danger small-button&quot; type=&quot;button&quot;&gt;
            키 삭제
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/section&gt;

    &lt;section class=&quot;card soft&quot;&gt;
      &lt;h2&gt;2. 수집 설정&lt;/h2&gt;

      &lt;div id=&quot;lockedMessage&quot; class=&quot;locked-message&quot;&gt;
        API 키를 저장하면 수집 설정이 활성화됩니다.
      &lt;/div&gt;

      &lt;fieldset id=&quot;collectorFieldset&quot; disabled&gt;
        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;videoUrl&quot;&gt;YouTube 영상 주소&lt;/label&gt;
          &lt;input
            id=&quot;videoUrl&quot;
            type=&quot;text&quot;
            placeholder=&quot;https://www.youtube.com/watch?v=...&quot;
          &gt;
        &lt;/div&gt;

        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;limit&quot;&gt;수집할 최상위 댓글 수&lt;/label&gt;
          &lt;input
            id=&quot;limit&quot;
            type=&quot;number&quot;
            min=&quot;1&quot;
            max=&quot;50000&quot;
            step=&quot;1&quot;
            value=&quot;100&quot;
          &gt;
          &lt;div class=&quot;help&quot;&gt;
            1~50,000개. 답글 포함 시 실제 저장 행 수가 이 값보다 많아질 수 있습니다.
          &lt;/div&gt;
        &lt;/div&gt;


        &lt;div class=&quot;field check-row&quot;&gt;
          &lt;input id=&quot;includeReplies&quot; type=&quot;checkbox&quot; checked&gt;
          &lt;label for=&quot;includeReplies&quot;&gt;
            대댓글도 모두 수집
          &lt;/label&gt;
        &lt;/div&gt;

        &lt;div class=&quot;help&quot; style=&quot;margin-bottom: 10px;&quot;&gt;
          새 수집을 시작할 때마다 ‘시트1’, ‘시트2’, ‘시트3’ 형식의 새 시트가 생성됩니다. 기존 수집 결과는 유지됩니다.
        &lt;/div&gt;

        &lt;button id=&quot;startButton&quot; class=&quot;primary&quot; type=&quot;button&quot;&gt;
          수집 시작
        &lt;/button&gt;
      &lt;/fieldset&gt;
    &lt;/section&gt;

    &lt;section id=&quot;progressCard&quot; class=&quot;card hidden&quot;&gt;
      &lt;div class=&quot;row between&quot;&gt;
        &lt;h2 style=&quot;margin: 0;&quot;&gt;진행 상태&lt;/h2&gt;
        &lt;span id=&quot;progressBadge&quot; class=&quot;badge warning&quot;&gt;대기 중&lt;/span&gt;
      &lt;/div&gt;

      &lt;div id=&quot;progressTitle&quot; class=&quot;progress-title&quot; style=&quot;margin-top: 10px;&quot;&gt;&lt;/div&gt;

      &lt;div class=&quot;progress-bar&quot;&gt;
        &lt;div id=&quot;progressFill&quot; class=&quot;progress-fill&quot;&gt;&lt;/div&gt;
      &lt;/div&gt;

      &lt;div class=&quot;metric-grid&quot;&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;댓글&lt;/div&gt;
          &lt;div id=&quot;topMetric&quot; class=&quot;metric-value&quot;&gt;0 / 0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;대댓글&lt;/div&gt;
          &lt;div id=&quot;replyMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;전체 저장 행&lt;/div&gt;
          &lt;div id=&quot;totalMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;API 호출 횟수&lt;/div&gt;
          &lt;div id=&quot;apiCallMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;p id=&quot;progressMessage&quot; class=&quot;help&quot;&gt;&lt;/p&gt;

      &lt;button id=&quot;cancelButton&quot; class=&quot;danger hidden&quot; type=&quot;button&quot; style=&quot;width: 100%; margin-top: 12px;&quot;&gt;
        진행 중인 수집 취소
      &lt;/button&gt;
    &lt;/section&gt;


    &lt;div
      id=&quot;errorModalBackdrop&quot;
      class=&quot;modal-backdrop hidden&quot;
      role=&quot;dialog&quot;
      aria-modal=&quot;true&quot;
      aria-labelledby=&quot;errorModalTitle&quot;
    &gt;
      &lt;div class=&quot;modal-panel&quot;&gt;
        &lt;div class=&quot;modal-header&quot;&gt;
          &lt;div class=&quot;modal-icon&quot; aria-hidden=&quot;true&quot;&gt;!&lt;/div&gt;

          &lt;div class=&quot;modal-heading&quot;&gt;
            &lt;h2 id=&quot;errorModalTitle&quot; class=&quot;modal-title&quot;&gt;
              문제를 확인해야 합니다.
            &lt;/h2&gt;
            &lt;div id=&quot;errorModalCode&quot; class=&quot;modal-code&quot;&gt;&lt;/div&gt;
          &lt;/div&gt;

          &lt;button
            id=&quot;errorModalCloseButton&quot;
            class=&quot;modal-close&quot;
            type=&quot;button&quot;
            aria-label=&quot;오류 안내 닫기&quot;
          &gt;
            ×
          &lt;/button&gt;
        &lt;/div&gt;

        &lt;div class=&quot;modal-body&quot;&gt;
          &lt;div id=&quot;errorModalSummary&quot; class=&quot;modal-summary&quot;&gt;&lt;/div&gt;

          &lt;section id=&quot;errorCauseSection&quot; class=&quot;modal-section&quot;&gt;
            &lt;h3&gt;발생 가능한 원인&lt;/h3&gt;
            &lt;ul id=&quot;errorCauseList&quot;&gt;&lt;/ul&gt;
          &lt;/section&gt;

          &lt;section id=&quot;errorStepSection&quot; class=&quot;modal-section&quot;&gt;
            &lt;h3&gt;해결 방법&lt;/h3&gt;
            &lt;ol id=&quot;errorStepList&quot;&gt;&lt;/ol&gt;
          &lt;/section&gt;

          &lt;details id=&quot;errorTechnicalDetails&quot; class=&quot;technical-details&quot;&gt;
            &lt;summary&gt;기술 정보 보기&lt;/summary&gt;
            &lt;div id=&quot;errorTechnicalGrid&quot; class=&quot;technical-grid&quot;&gt;&lt;/div&gt;
          &lt;/details&gt;
        &lt;/div&gt;

        &lt;div class=&quot;modal-actions&quot;&gt;
          &lt;a
            id=&quot;errorHelpLink&quot;
            class=&quot;modal-help-link hidden&quot;
            href=&quot;#&quot;
            target=&quot;_blank&quot;
            rel=&quot;noopener noreferrer&quot;
          &gt;&lt;/a&gt;

          &lt;button
            id=&quot;errorModalPrimaryButton&quot;
            class=&quot;primary&quot;
            type=&quot;button&quot;
          &gt;
            확인
          &lt;/button&gt;

          &lt;button
            id=&quot;errorModalDismissButton&quot;
            class=&quot;secondary&quot;
            type=&quot;button&quot;
          &gt;
            닫기
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;script&gt;
      const ERROR_PREFIX = &#39;YT_STRUCTURED_ERROR::&#39;;

      let currentState = null;
      let pollTimer = null;
      let pollInFlight = false;
      let lastShownErrorKey = &#39;&#39;;
      let pendingFocusTarget = &#39;&#39;;

      document.addEventListener(&#39;DOMContentLoaded&#39;, function() {
        bindEvents();
        refreshState(false);
      });

      function bindEvents() {
        document.getElementById(&#39;saveApiButton&#39;).addEventListener(&#39;click&#39;, saveApiKey);
        document.getElementById(&#39;changeApiButton&#39;).addEventListener(&#39;click&#39;, showApiKeyEditor);
        document.getElementById(&#39;deleteApiButton&#39;).addEventListener(&#39;click&#39;, deleteApiKey);
        document.getElementById(&#39;startButton&#39;).addEventListener(&#39;click&#39;, startCollection);
        document.getElementById(&#39;cancelButton&#39;).addEventListener(&#39;click&#39;, cancelCollection);

        document
          .getElementById(&#39;errorModalCloseButton&#39;)
          .addEventListener(&#39;click&#39;, closeErrorModal);

        document
          .getElementById(&#39;errorModalDismissButton&#39;)
          .addEventListener(&#39;click&#39;, closeErrorModal);

        document
          .getElementById(&#39;errorModalPrimaryButton&#39;)
          .addEventListener(&#39;click&#39;, handleErrorModalPrimaryAction);

        document
          .getElementById(&#39;errorModalBackdrop&#39;)
          .addEventListener(&#39;click&#39;, function(event) {
            if (event.target === event.currentTarget) {
              closeErrorModal();
            }
          });

        document.addEventListener(&#39;keydown&#39;, function(event) {
          if (event.key === &#39;Escape&#39;) {
            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, &#39;상태 확인&#39;);
            }
          })
          .getSidebarState();
      }

      function renderState(state) {
        currentState = state || {};

        const configured = Boolean(currentState.apiKeyConfigured);
        const running = Boolean(currentState.running);

        document.getElementById(&#39;apiMissingView&#39;).classList.toggle(&#39;hidden&#39;, configured);
        document.getElementById(&#39;apiSavedView&#39;).classList.toggle(&#39;hidden&#39;, !configured);
        document.getElementById(&#39;apiKeyMask&#39;).textContent = currentState.apiKeyMask || &#39;&#39;;

        document.getElementById(&#39;collectorFieldset&#39;).disabled = !configured || running;
        document.getElementById(&#39;lockedMessage&#39;).classList.toggle(&#39;hidden&#39;, configured);

        document.getElementById(&#39;changeApiButton&#39;).disabled = running;
        document.getElementById(&#39;deleteApiButton&#39;).disabled = running;

        renderProgress(currentState.progress, running);

        if (running) {
          startPolling();
        } else {
          stopPolling();
        }
      }

      function renderProgress(progress, running) {
        const card = document.getElementById(&#39;progressCard&#39;);

        if (!progress) {
          card.classList.add(&#39;hidden&#39;);
          return;
        }

        card.classList.remove(&#39;hidden&#39;);

        const status = progress.status || &#39;&#39;;
        const badge = document.getElementById(&#39;progressBadge&#39;);
        badge.textContent = progress.stage || status;
        badge.className = &#39;badge &#39; + getBadgeClass(status);

        const progressTitle =
          progress.videoTitle || progress.videoId || &#39;YouTube 영상&#39;;
        const outputSheetName = progress.outputSheetName || &#39;&#39;;

        document.getElementById(&#39;progressTitle&#39;).textContent =
          outputSheetName
            ? progressTitle + &#39; · &#39; + outputSheetName
            : progressTitle;

        document.getElementById(&#39;progressFill&#39;).style.width =
          String(progress.percent || 0) + &#39;%&#39;;

        document.getElementById(&#39;topMetric&#39;).textContent =
          formatNumber(progress.topCount) + &#39; / &#39; + formatNumber(progress.targetTopLevelCount);

        document.getElementById(&#39;replyMetric&#39;).textContent = formatNumber(progress.replyCount);
        document.getElementById(&#39;totalMetric&#39;).textContent = formatNumber(progress.totalRows);
        document.getElementById(&#39;apiCallMetric&#39;).textContent = formatNumber(progress.apiCalls);
        document.getElementById(&#39;progressMessage&#39;).textContent = progress.message || &#39;&#39;;
        document.getElementById(&#39;cancelButton&#39;).classList.toggle(&#39;hidden&#39;, !running);

        if (status === &#39;ERROR&#39;) {
          const errorInfo = progress.error || buildFallbackErrorInfo(
            progress.message || &#39;&#39;,
            &#39;댓글 수집&#39;
          );

          const errorKey = [
            errorInfo.code || &#39;UNKNOWN_ERROR&#39;,
            progress.updatedAt || &#39;&#39;,
            progress.videoId || &#39;&#39;
          ].join(&#39;|&#39;);

          if (errorKey !== lastShownErrorKey) {
            lastShownErrorKey = errorKey;
            showErrorModal(errorInfo);
          }
        }
      }

      function saveApiKey() {
        const input = document.getElementById(&#39;apiKey&#39;);
        const button = document.getElementById(&#39;saveApiButton&#39;);
        const apiKey = input.value.trim();

        if (!apiKey) {
          showErrorModal({
            code: &#39;API_KEY_REQUIRED&#39;,
            title: &#39;API 키를 입력해야 합니다.&#39;,
            summary: &#39;API 키 입력란이 비어 있어 검증을 시작할 수 없습니다.&#39;,
            causes: [
              &#39;입력란에 값이 입력되지 않았습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’에서 API 키를 확인합니다.&#39;,
              &#39;키 값을 복사하여 입력란에 붙여 넣습니다.&#39;,
              &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
            ],
            helpLabel: &#39;API 키 관리 화면 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;API 키 입력&#39;
          });
          return;
        }

        setButtonBusy(button, true, &#39;확인 중&#39;);
        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            input.value = &#39;&#39;;
            setButtonBusy(button, false, &#39;API 키 확인 후 저장&#39;);
            renderState(state);
            showMessage(&#39;API 키를 검증하여 저장했습니다.&#39;, &#39;success&#39;);
          })
          .withFailureHandler(function(error) {
            setButtonBusy(button, false, &#39;API 키 확인 후 저장&#39;);
            handleServerError(error, &#39;API 키 검증&#39;);
          })
          .saveApiKey(apiKey);
      }

      function showApiKeyEditor() {
        document.getElementById(&#39;apiSavedView&#39;).classList.add(&#39;hidden&#39;);
        document.getElementById(&#39;apiMissingView&#39;).classList.remove(&#39;hidden&#39;);
        document.getElementById(&#39;apiKey&#39;).focus();
        showMessage(&#39;새 API 키를 입력하면 기존 키가 교체됩니다.&#39;, &#39;info&#39;);
      }

      function deleteApiKey() {
        if (!window.confirm(&#39;저장된 API 키를 삭제하시겠습니까?&#39;)) {
          return;
        }

        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            renderState(state);
            showMessage(&#39;저장된 API 키를 삭제했습니다.&#39;, &#39;success&#39;);
          })
          .withFailureHandler(function(error) {
            handleServerError(error, &#39;API 키 삭제&#39;);
          })
          .deleteApiKey();
      }

      function startCollection() {
        const videoUrl = document.getElementById(&#39;videoUrl&#39;).value.trim();
        const limitText = document.getElementById(&#39;limit&#39;).value;
        const limit = Number(limitText);
        const startButton = document.getElementById(&#39;startButton&#39;);

        if (!videoUrl) {
          showErrorModal({
            code: &#39;VIDEO_URL_REQUIRED&#39;,
            title: &#39;영상 주소를 입력해야 합니다.&#39;,
            summary: &#39;수집 대상 YouTube 영상 주소가 비어 있습니다.&#39;,
            causes: [
              &#39;영상 주소 입력란에 값이 없습니다.&#39;
            ],
            steps: [
              &#39;YouTube에서 수집할 영상을 엽니다.&#39;,
              &#39;주소창의 전체 영상 주소를 복사하여 입력합니다.&#39;
            ],
            focusTarget: &#39;videoUrl&#39;,
            actionLabel: &#39;영상 주소 입력&#39;
          });
          return;
        }

        if (!Number.isInteger(limit) || limit &lt; 1 || limit &gt; 50000) {
          showErrorModal({
            code: &#39;COMMENT_LIMIT_INVALID&#39;,
            title: &#39;수집 개수를 확인해야 합니다.&#39;,
            summary: &#39;수집 개수는 1~50,000 사이의 정수로 입력해야 합니다.&#39;,
            causes: [
              &#39;빈 값, 소수, 문자 또는 허용 범위를 벗어난 수가 입력되었습니다.&#39;
            ],
            steps: [
              &#39;수집 개수 입력란에 1~50,000 사이의 정수를 입력합니다.&#39;,
              &#39;예: 100, 500, 1000&#39;
            ],
            focusTarget: &#39;limit&#39;,
            actionLabel: &#39;수집 개수 수정&#39;
          });
          return;
        }

        const payload = {
          videoUrl: videoUrl,
          limit: limit,
          includeReplies: document.getElementById(&#39;includeReplies&#39;).checked
        };

        setButtonBusy(startButton, true, &#39;준비 중&#39;);
        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            setButtonBusy(startButton, false, &#39;수집 시작&#39;);
            renderState(state);
            const sheetName =
              state &amp;&amp;
              state.progress &amp;&amp;
              state.progress.outputSheetName
                ? state.progress.outputSheetName
                : &#39;&#39;;

            showMessage(
              sheetName
                ? sheetName + &#39;에서 수집 작업을 시작했습니다.&#39;
                : &#39;수집 작업을 시작했습니다.&#39;,
              &#39;info&#39;
            );
            startPolling();

            // 서버에서 첫 수집 구간을 즉시 실행합니다.
            google.script.run
              .withSuccessHandler(function(updatedState) {
                renderState(updatedState);
              })
              .withFailureHandler(function(error) {
                refreshState(true);
                handleServerError(error, &#39;댓글 수집&#39;);
              })
              .runCollectionNow();
          })
          .withFailureHandler(function(error) {
            setButtonBusy(startButton, false, &#39;수집 시작&#39;);
            handleServerError(error, &#39;수집 준비&#39;);
          })
          .startCollectionFromSidebar(payload);
      }

      function cancelCollection() {
        if (!window.confirm(&#39;현재 수집 작업을 취소하시겠습니까? 이미 저장된 행은 유지됩니다.&#39;)) {
          return;
        }

        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            renderState(state);

            if (state &amp;&amp; state.running) {
              showMessage(&#39;취소 요청을 보냈습니다. 현재 API 페이지 처리가 끝나면 안전하게 중단됩니다.&#39;, &#39;info&#39;);
              startPolling();
            } else {
              showMessage(&#39;수집 작업을 취소했습니다.&#39;, &#39;success&#39;);
            }
          })
          .withFailureHandler(function(error) {
            handleServerError(error, &#39;수집 취소&#39;);
          })
          .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 = &#39;&lt;span class=&quot;row&quot; style=&quot;justify-content:center;&quot;&gt;&lt;span class=&quot;spinner&quot;&gt;&lt;/span&gt;&lt;span&gt;&#39; +
            escapeHtml(label) +
            &#39;&lt;/span&gt;&lt;/span&gt;&#39;;
        } else {
          button.textContent = label;
        }
      }

      function showMessage(text, type) {
        const box = document.getElementById(&#39;message&#39;);
        box.textContent = text || &#39;&#39;;
        box.className = &#39;message show &#39; + (type || &#39;info&#39;);
      }

      function clearMessage() {
        const box = document.getElementById(&#39;message&#39;);
        box.textContent = &#39;&#39;;
        box.className = &#39;message&#39;;
      }

      function getBadgeClass(status) {
        if (status === &#39;COMPLETED&#39;) {
          return &#39;success&#39;;
        }

        if (status === &#39;ERROR&#39; || status === &#39;CANCELLED&#39;) {
          return &#39;danger&#39;;
        }

        return &#39;warning&#39;;
      }

      function handleServerError(error, context) {
        const errorInfo = parseServerError(error, context);
        showErrorModal(errorInfo);
      }

      function parseServerError(error, context) {
        if (error &amp;&amp; error.title &amp;&amp; error.summary) {
          return normalizeClientErrorInfo(error);
        }

        const raw = error &amp;&amp; error.message
          ? String(error.message)
          : String(error || &#39;&#39;);

        const structured = extractStructuredError(raw);

        if (structured) {
          return normalizeClientErrorInfo(structured);
        }

        return buildFallbackErrorInfo(raw, context);
      }

      function extractStructuredError(rawText) {
        const raw = String(rawText || &#39;&#39;);
        const markerIndex = raw.indexOf(ERROR_PREFIX);

        if (markerIndex &lt; 0) {
          return null;
        }

        const objectStart = raw.indexOf(
          &#39;{&#39;,
          markerIndex + ERROR_PREFIX.length
        );

        if (objectStart &lt; 0) {
          return null;
        }

        let depth = 0;
        let inString = false;
        let escaped = false;

        for (let index = objectStart; index &lt; raw.length; index += 1) {
          const character = raw.charAt(index);

          if (inString) {
            if (escaped) {
              escaped = false;
            } else if (character === &#39;\\&#39;) {
              escaped = true;
            } else if (character === &#39;&quot;&#39;) {
              inString = false;
            }

            continue;
          }

          if (character === &#39;&quot;&#39;) {
            inString = true;
            continue;
          }

          if (character === &#39;{&#39;) {
            depth += 1;
          } else if (character === &#39;}&#39;) {
            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 || &#39;UNKNOWN_ERROR&#39;),
          title: String(info.title || &#39;작업을 완료하지 못했습니다.&#39;),
          summary: String(
            info.summary ||
            &#39;처리 중 예상하지 못한 문제가 발생했습니다.&#39;
          ),
          causes: Array.isArray(info.causes)
            ? info.causes.map(String)
            : [],
          steps: Array.isArray(info.steps)
            ? info.steps.map(String)
            : [],
          helpLabel: String(info.helpLabel || &#39;&#39;),
          helpUrl: String(info.helpUrl || &#39;&#39;),
          focusTarget: String(info.focusTarget || &#39;&#39;),
          actionLabel: String(info.actionLabel || &#39;확인&#39;),
          technical:
            info.technical &amp;&amp; typeof info.technical === &#39;object&#39;
              ? info.technical
              : {}
        };
      }

      function buildFallbackErrorInfo(rawText, context) {
        const raw = String(rawText || &#39;&#39;);
        const lower = raw.toLowerCase();
        const technical = {
          errorCode: &#39;UNCLASSIFIED_CLIENT_ERROR&#39;,
          context: String(context || &#39;작업 실행&#39;)
        };

        if (
          lower.indexOf(&#39;api key not valid&#39;) &gt;= 0 ||
          lower.indexOf(&#39;keyinvalid&#39;) &gt;= 0 ||
          lower.indexOf(&#39;api_key_invalid&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;API_KEY_INVALID&#39;,
            title: &#39;API 키를 사용할 수 없습니다.&#39;,
            summary: &#39;입력한 API 키가 정확하지 않거나 현재 사용할 수 없는 상태입니다.&#39;,
            causes: [
              &#39;키 일부가 누락되었거나 다른 값을 붙여 넣었을 수 있습니다.&#39;,
              &#39;Google Cloud에서 기존 키를 삭제하거나 재발급했을 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
              &#39;실제 API 키 값을 전체 복사합니다.&#39;,
              &#39;사이드바에서 키를 다시 입력하고 저장합니다.&#39;
            ],
            helpLabel: &#39;API 키 관리 화면 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;API 키 다시 입력&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;API_KEY_INVALID&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;access not configured&#39;) &gt;= 0 ||
          lower.indexOf(&#39;accessnotconfigured&#39;) &gt;= 0 ||
          lower.indexOf(&#39;has not been used in project&#39;) &gt;= 0 ||
          lower.indexOf(&#39;api_disabled&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;YOUTUBE_API_NOT_ENABLED&#39;,
            title: &#39;YouTube Data API v3가 활성화되어 있지 않습니다.&#39;,
            summary: &#39;API 키가 속한 Google Cloud 프로젝트에서 YouTube Data API v3 사용 설정을 켜야 합니다.&#39;,
            causes: [
              &#39;API 키를 만든 프로젝트에서 YouTube Data API v3를 아직 활성화하지 않았습니다.&#39;,
              &#39;API를 활성화한 프로젝트와 API 키를 발급한 프로젝트가 다를 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console에서 API 키를 만든 프로젝트를 선택합니다.&#39;,
              &#39;‘API 및 서비스 → 라이브러리’로 이동합니다.&#39;,
              &#39;‘YouTube Data API v3’를 검색하고 ‘사용’을 누릅니다.&#39;,
              &#39;설정 후 1~5분 뒤 API 키를 다시 저장합니다.&#39;
            ],
            helpLabel: &#39;YouTube Data API 설정 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/library/youtube.googleapis.com&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;설정 확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;YOUTUBE_API_NOT_ENABLED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;commentsdisabled&#39;) &gt;= 0 ||
          lower.indexOf(&#39;comments disabled&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;COMMENTS_DISABLED&#39;,
            title: &#39;이 영상에서는 댓글을 수집할 수 없습니다.&#39;,
            summary: &#39;영상 게시자가 댓글 기능을 사용하지 않도록 설정했습니다.&#39;,
            causes: [
              &#39;영상의 댓글 기능이 꺼져 있거나 YouTube 정책에 따라 제한되었습니다.&#39;
            ],
            steps: [
              &#39;영상 페이지에서 댓글 영역이 표시되는지 확인합니다.&#39;,
              &#39;댓글이 허용된 다른 영상 주소를 입력합니다.&#39;
            ],
            focusTarget: &#39;videoUrl&#39;,
            actionLabel: &#39;영상 주소 다시 입력&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;COMMENTS_DISABLED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;quotaexceeded&#39;) &gt;= 0 ||
          lower.indexOf(&#39;quota_exceeded&#39;) &gt;= 0 ||
          lower.indexOf(&#39;daily limit&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;YOUTUBE_QUOTA_EXCEEDED&#39;,
            title: &#39;YouTube API의 오늘 할당량을 모두 사용했습니다.&#39;,
            summary: &#39;현재 프로젝트에서 오늘 사용할 수 있는 YouTube Data API 할당량이 남아 있지 않습니다.&#39;,
            causes: [
              &#39;같은 프로젝트의 다른 작업에서도 YouTube API를 사용했을 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console에서 YouTube Data API의 오늘 사용량을 확인합니다.&#39;,
              &#39;일일 할당량이 갱신된 뒤 다시 실행합니다.&#39;,
              &#39;다음 수집에서는 요청 댓글 수를 줄입니다.&#39;
            ],
            helpLabel: &#39;YouTube API 할당량 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas&#39;,
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;YOUTUBE_QUOTA_EXCEEDED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;authorization is required&#39;) &gt;= 0 ||
          lower.indexOf(&#39;you do not have permission&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;,
            title: &#39;Google 권한 승인이 필요합니다.&#39;,
            summary: &#39;현재 계정이 스프레드시트 또는 외부 API 요청 권한을 아직 승인하지 않았습니다.&#39;,
            causes: [
              &#39;Apps Script를 처음 실행했거나 새로운 권한이 추가되었습니다.&#39;
            ],
            steps: [
              &#39;스프레드시트 메뉴에서 사이드바를 다시 엽니다.&#39;,
              &#39;권한 검토 화면에서 현재 계정을 선택합니다.&#39;,
              &#39;요청되는 권한을 허용한 뒤 다시 실행합니다.&#39;
            ],
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;timed out&#39;) &gt;= 0 ||
          lower.indexOf(&#39;maximum execution time&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;,
            title: &#39;한 번의 실행 가능 시간을 초과했습니다.&#39;,
            summary: &#39;댓글이 많아 현재 실행 구간이 종료되었습니다.&#39;,
            causes: [
              &#39;대량 댓글 또는 답글 처리 시간이 길어졌습니다.&#39;
            ],
            steps: [
              &#39;진행 상태가 자동으로 이어지는지 확인합니다.&#39;,
              &#39;멈춘 경우 ‘중단 지점부터 실행’을 한 번 실행합니다.&#39;,
              &#39;반복되면 수집 개수를 줄입니다.&#39;
            ],
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;
            })
          });
        }

        return normalizeClientErrorInfo({
          code: &#39;UNEXPECTED_ERROR&#39;,
          title: &#39;예상하지 못한 오류가 발생했습니다.&#39;,
          summary: &#39;오류 유형을 자동으로 판별하지 못했습니다. 입력값과 Apps Script 실행 기록을 확인해야 합니다.&#39;,
          causes: [
            &#39;네트워크 연결, Google 서비스 상태, 권한 또는 실행 환경에서 문제가 발생했을 수 있습니다.&#39;
          ],
          steps: [
            &#39;사이드바를 닫았다가 다시 열고 같은 작업을 한 번 더 실행합니다.&#39;,
            &#39;Apps Script 편집기의 왼쪽 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;,
            &#39;API 키 저장 상태와 영상 주소를 다시 확인합니다.&#39;
          ],
          actionLabel: &#39;확인&#39;,
          technical: technical
        });
      }

      function showErrorModal(errorInfo) {
        const info = normalizeClientErrorInfo(errorInfo);
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);
        const title = document.getElementById(&#39;errorModalTitle&#39;);
        const code = document.getElementById(&#39;errorModalCode&#39;);
        const summary = document.getElementById(&#39;errorModalSummary&#39;);
        const causeSection = document.getElementById(&#39;errorCauseSection&#39;);
        const stepSection = document.getElementById(&#39;errorStepSection&#39;);
        const causeList = document.getElementById(&#39;errorCauseList&#39;);
        const stepList = document.getElementById(&#39;errorStepList&#39;);
        const helpLink = document.getElementById(&#39;errorHelpLink&#39;);
        const primaryButton = document.getElementById(&#39;errorModalPrimaryButton&#39;);

        clearMessage();

        title.textContent = info.title;
        code.textContent = &#39;오류 코드: &#39; + info.code;
        summary.textContent = info.summary;

        renderTextList(causeList, info.causes);
        renderTextList(stepList, info.steps);

        causeSection.classList.toggle(&#39;hidden&#39;, info.causes.length === 0);
        stepSection.classList.toggle(&#39;hidden&#39;, info.steps.length === 0);

        if (
          info.helpUrl &amp;&amp;
          /^https:\/\/console\.cloud\.google\.com\//.test(info.helpUrl)
        ) {
          helpLink.href = info.helpUrl;
          helpLink.textContent = info.helpLabel || &#39;Google Cloud 설정 열기&#39;;
          helpLink.classList.remove(&#39;hidden&#39;);
        } else {
          helpLink.removeAttribute(&#39;href&#39;);
          helpLink.textContent = &#39;&#39;;
          helpLink.classList.add(&#39;hidden&#39;);
        }

        primaryButton.textContent = info.actionLabel || &#39;확인&#39;;
        pendingFocusTarget = info.focusTarget || &#39;&#39;;

        renderTechnicalInfo(info.technical);

        backdrop.classList.remove(&#39;hidden&#39;);
        document.body.style.overflow = &#39;hidden&#39;;

        window.setTimeout(function() {
          primaryButton.focus();
        }, 0);
      }

      function renderTextList(listElement, items) {
        listElement.innerHTML = &#39;&#39;;

        (items || []).forEach(function(item) {
          const listItem = document.createElement(&#39;li&#39;);
          listItem.textContent = String(item);
          listElement.appendChild(listItem);
        });
      }

      function renderTechnicalInfo(technical) {
        const details = document.getElementById(&#39;errorTechnicalDetails&#39;);
        const grid = document.getElementById(&#39;errorTechnicalGrid&#39;);
        const data = technical &amp;&amp; typeof technical === &#39;object&#39;
          ? technical
          : {};

        const labels = {
          errorCode: &#39;분류 코드&#39;,
          httpStatus: &#39;HTTP 상태&#39;,
          reason: &#39;API 사유 코드&#39;,
          resource: &#39;요청 자원&#39;,
          videoId: &#39;영상 ID&#39;,
          context: &#39;실행 단계&#39;
        };

        grid.innerHTML = &#39;&#39;;

        Object.keys(labels).forEach(function(key) {
          if (
            data[key] === undefined ||
            data[key] === null ||
            data[key] === &#39;&#39;
          ) {
            return;
          }

          const label = document.createElement(&#39;div&#39;);
          label.className = &#39;technical-label&#39;;
          label.textContent = labels[key];

          const value = document.createElement(&#39;div&#39;);
          value.className = &#39;technical-value&#39;;
          value.textContent = String(data[key]);

          grid.appendChild(label);
          grid.appendChild(value);
        });

        details.classList.toggle(&#39;hidden&#39;, grid.children.length === 0);
        details.open = false;
      }

      function closeErrorModal() {
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);

        if (backdrop.classList.contains(&#39;hidden&#39;)) {
          return;
        }

        backdrop.classList.add(&#39;hidden&#39;);
        document.body.style.overflow = &#39;&#39;;
        pendingFocusTarget = &#39;&#39;;
      }

      function handleErrorModalPrimaryAction() {
        const focusTarget = pendingFocusTarget;
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);

        backdrop.classList.add(&#39;hidden&#39;);
        document.body.style.overflow = &#39;&#39;;
        pendingFocusTarget = &#39;&#39;;

        focusErrorTarget(focusTarget);
      }

      function focusErrorTarget(targetId) {
        if (!targetId) {
          return;
        }

        if (targetId === &#39;apiKey&#39;) {
          document.getElementById(&#39;apiSavedView&#39;).classList.add(&#39;hidden&#39;);
          document.getElementById(&#39;apiMissingView&#39;).classList.remove(&#39;hidden&#39;);
        }

        const target = document.getElementById(targetId);

        if (target) {
          window.setTimeout(function() {
            target.focus();

            if (typeof target.select === &#39;function&#39;) {
              target.select();
            }
          }, 0);
        }
      }

      function formatNumber(value) {
        return Number(value || 0).toLocaleString(&#39;ko-KR&#39;);
      }

      function escapeHtml(value) {
        return String(value || &#39;&#39;)
          .replace(/&amp;/g, &#39;&amp;amp;&#39;)
          .replace(/&lt;/g, &#39;&amp;lt;&#39;)
          .replace(/&gt;/g, &#39;&amp;gt;&#39;)
          .replace(/&quot;/g, &#39;&amp;quot;&#39;)
          .replace(/&#39;/g, &#39;&amp;#039;&#39;);
      }
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</pre></section><h3>관련 링크</h3><ul><li><a href="https://brunch.co.kr/@mystoryg/156">Youtube Data API 키 발급 방법 안내</a></li></ul></section><section><h2>(3) 바른 API KEY 발급</h2><h3>관련 링크</h3><ul><li><a href="https://nara-speller.co.kr/speller/">하나씩 검사하기 - 바른한글</a></li><li><a href="https://bareun.ai/api-key">API로 대량 검사하기 - 바른AI</a></li></ul></section><section><h2>(4) 민기쌤 생기부 도우미</h2><section><h3>Code.gs</h3><pre>// =================================================================
// [0] 초기 설정 및 메뉴
// =================================================================

function onOpen() {
  SpreadsheetApp.getUi().createMenu(&#39;민기쌤 생기부 도우미&#39;)
      .addItem(&#39;🚀 사이드바 열기&#39;, &#39;openSidebar&#39;) 
      .addToUi();
}

function renderView_(viewName) {
  var template = HtmlService.createTemplateFromFile(&#39;Sidebar&#39;);
  template.initialView = viewName || &#39;sidebar&#39;;
  return template.evaluate();
}

function openSidebar() {
  ensureBannedWordSheet_();
  var html = renderView_(&#39;sidebar&#39;)
      .setTitle(&#39;민기쌤 생기부 맞춤법 도우미&#39;)
      .setWidth(300); 
  SpreadsheetApp.getUi().showSidebar(html);
}

function openSpellCheckViewer() {
  var html = renderView_(&#39;spellcheck&#39;)
      .setWidth(1000) 
      .setHeight(700);
  SpreadsheetApp.getUi().showModelessDialog(html, &#39;📝 Bareun 맞춤법 검사 및 수정&#39;);
}

function openBannedWordViewer() {
  var html = renderView_(&#39;banned&#39;)
      .setWidth(1000)
      .setHeight(700);
  SpreadsheetApp.getUi().showModelessDialog(html, &#39;🚫 기재 금지어 점검&#39;);
}

// =================================================================
// [1] Bareun API 키 및 UI 설정
// =================================================================

function getBareunUserProperties_() {
  ensureBannedWordSheet_();
  var props = PropertiesService.getUserProperties();
  var currentPolicy = &#39;BAREUN_ONLY_KEYS_V3&#39;;
  var previousPolicy = props.getProperty(&#39;API_KEY_POLICY_VERSION&#39;);

  // 정책 변경 시 현재 기능에서 쓰지 않는 예전 사용자 설정을 한 번 정리합니다.
  if (previousPolicy !== currentPolicy) {
    var allowedProperties = {
      UI_STATE: true,
      API_KEY_POLICY_VERSION: true
    };
    // 바로 이전 사용자 입력형 정책에서 저장한 키만 보존합니다.
    if (previousPolicy === &#39;USER_SUPPLIED_KEYS_V1&#39; ||
        previousPolicy === &#39;BAREUN_ONLY_KEYS_V1&#39; ||
        previousPolicy === &#39;BAREUN_ONLY_KEYS_V2&#39;) {
      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(&#39;UI_STATE&#39;) || &#39;{}&#39;);
    } catch (e) {
      previousUiState = {};
    }
    var currentUiState = {};
    [&#39;card-api&#39;, &#39;sub-api-settings&#39;, &#39;card-util&#39;].forEach(function(id) {
      if (previousUiState[id]) currentUiState[id] = true;
    });
    props.setProperty(&#39;UI_STATE&#39;, JSON.stringify(currentUiState));
    props.setProperty(&#39;API_KEY_POLICY_VERSION&#39;, currentPolicy);
  }

  return props;
}

function saveUserBareunKey(bareunKey) {
  var props = getBareunUserProperties_();
  var cleanBareunKey = String(bareunKey || &#39;&#39;).trim();

  if (!cleanBareunKey) {
    throw new Error(&#39;Bareun API 키를 입력해 주세요. 이 도우미는 API 키를 제공하지 않습니다.&#39;);
  }

  props.setProperty(&#39;USER_BAREUN_KEY&#39;, cleanBareunKey);
  return { hasBareun: true };
}

function checkUserBareunStatus() {
  var props = getBareunUserProperties_();
  return { hasBareun: !!props.getProperty(&#39;USER_BAREUN_KEY&#39;) };
}

function clearUserBareunKey() {
  var props = getBareunUserProperties_();
  props.deleteProperty(&#39;USER_BAREUN_KEY&#39;);
  return { hasBareun: false };
}

function getBareunKey() {
  var key = getBareunUserProperties_().getProperty(&#39;USER_BAREUN_KEY&#39;);
  if (!key) throw new Error(&quot;Bareun API 키가 없습니다. 사이드바 상단에서 등록해주세요.&quot;);
  return key;
}

// =================================================================
// [2] Bareun 맞춤법 검사
// =================================================================

function getSelectedDataForSpellCheck() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var sheetName = sheet.getName();
  var range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);
  var rows = [];
  var startRow = range.getRow();
  var startCol = range.getColumn();
  var values = range.getValues();
  for (var i = 0; i &lt; values.length; i++) {
    for (var j = 0; j &lt; values[0].length; j++) {
      var text = values[i][j].toString();
      if (text.trim() !== &quot;&quot;) {
        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(&#39;검사한 시트를 찾을 수 없습니다: &#39; + item.sheetName);
    sheet.getRange(item.row, item.col).setValue(item.text);
  });
  return updates.length;
}

function callBareunApi(text, userKey) {
  var url = &quot;https://api.bareun.ai/bareun.RevisionService/CorrectError&quot;;
  var options = {
    &quot;method&quot;: &quot;post&quot;, &quot;contentType&quot;: &quot;application/json&quot;, &quot;headers&quot;: { &quot;api-key&quot;: userKey }, &quot;muteHttpExceptions&quot;: true,
    &quot;payload&quot;: JSON.stringify({ &quot;document&quot;: { &quot;content&quot;: text, &quot;language&quot;: &quot;ko-KR&quot; }, &quot;encoding_type&quot;: &quot;UTF16&quot; })
  };
  var response = UrlFetchApp.fetch(url, options);
  var result = JSON.parse(response.getContentText());
  
  if (response.getResponseCode() !== 200) {
    return { error: &quot;API 오류: &quot; + response.getResponseCode() };
  }
  return { revised: result.revised, helps: result.helps || {}, rawBlocks: result.revisedBlocks || result.revised_blocks || [], error: null };
}

// =================================================================
// [3] 기재 금지어 관리
// =================================================================

var BANNED_WORD_SHEET_NAME_ = &#39;기재금지어관리&#39;;
var BANNED_WORD_HEADERS_ = [&#39;번호&#39;, &#39;기재 금지어&#39;];
var LEGACY_BANNED_WORD_HEADERS_ = [&#39;유형&#39;, &#39;기재 금지어&#39;, &#39;대체 표현&#39;];
var ONE_COLUMN_BANNED_WORD_HEADERS_ = [&#39;기재 금지어&#39;];

function getLegacyBannedWordList_() {
  var saved = PropertiesService.getUserProperties().getProperty(&#39;BANNED_WORDS_DB&#39;);
  if (!saved) return getDefaultBannedList_();

  try {
    var parsed = JSON.parse(saved);
    if (!Array.isArray(parsed)) return getDefaultBannedList_();
    return parsed
      .map(function(item) {
        return String(item &amp;&amp; item.k || &#39;&#39;).trim();
      })
      .filter(function(keyword) { return keyword !== &#39;&#39;; });
  } catch (e) {
    return getDefaultBannedList_();
  }
}

function initializeBannedWordSheet_(sheet, list) {
  sheet.getRange(1, 1, 1, BANNED_WORD_HEADERS_.length)
    .setValues([BANNED_WORD_HEADERS_])
    .setBackground(&#39;#fce8e6&#39;)
    .setFontColor(&#39;#a50e0e&#39;)
    .setFontWeight(&#39;bold&#39;)
    .setHorizontalAlignment(&#39;center&#39;);

  var rows = list.map(function(keyword, index) {
    return [index + 1, keyword];
  });
  if (rows.length &gt; 0) sheet.getRange(2, 1, rows.length, 2).setValues(rows);

  sheet.setFrozenRows(1);
  sheet.setColumnWidth(1, 60);
  sheet.setColumnWidth(2, 260);
  sheet.setTabColor(&#39;#d93025&#39;);
}

function migrateLegacyBannedWordSheet_(sheet) {
  var lastColumn = sheet.getLastColumn();
  var legacyColumn;
  var clearColumns;

  if (lastColumn &gt;= 3 &amp;&amp; sheet.getRange(1, 1, 1, 3).getDisplayValues()[0].join(&#39;|&#39;) === LEGACY_BANNED_WORD_HEADERS_.join(&#39;|&#39;)) {
    legacyColumn = 2;
    clearColumns = 3;
  } else if (sheet.getRange(1, 1, 1, 1).getDisplayValues()[0][0] === ONE_COLUMN_BANNED_WORD_HEADERS_[0]) {
    legacyColumn = 1;
    clearColumns = 1;
  } else {
    return false;
  }

  var lastRow = sheet.getLastRow();
  var keywords = lastRow &lt; 2 ? [] : sheet.getRange(2, legacyColumn, lastRow - 1, 1)
    .getDisplayValues()
    .map(function(row) { return String(row[0] || &#39;&#39;).trim(); })
    .filter(function(keyword) { return keyword !== &#39;&#39;; });

  var clearRows = Math.max(sheet.getLastRow(), 1);
  sheet.getRange(1, 1, clearRows, clearColumns).clearContent();
  initializeBannedWordSheet_(sheet, keywords);
  return true;
}

function ensureBannedWordSheet_() {
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  if (!spreadsheet) throw new Error(&#39;활성 스프레드시트를 찾을 수 없습니다.&#39;);

  var originalSheet = spreadsheet.getActiveSheet();
  var originalRange = spreadsheet.getActiveRange();
  var lock = LockService.getDocumentLock() || LockService.getScriptLock();
  lock.waitLock(30000);

  var sheet;
  try {
    sheet = spreadsheet.getSheetByName(BANNED_WORD_SHEET_NAME_);
    if (!sheet) {
      sheet = spreadsheet.insertSheet(BANNED_WORD_SHEET_NAME_);
      initializeBannedWordSheet_(sheet, getLegacyBannedWordList_());
    } else {
      // 이전 형식은 금지어만 보존하여 번호·기재금지어 두 열 목록으로 안전하게 바꿉니다.
      migrateLegacyBannedWordSheet_(sheet);
    }

    // 시트로 옮긴 뒤 예전 사용자별 저장값은 더 이상 별도 원본으로 남기지 않습니다.
    PropertiesService.getUserProperties().deleteProperty(&#39;BANNED_WORDS_DB&#39;);
    return sheet;
  } finally {
    if (originalSheet &amp;&amp; sheet &amp;&amp; originalSheet.getSheetId() !== sheet.getSheetId()) {
      spreadsheet.setActiveSheet(originalSheet);
      if (originalRange) spreadsheet.setActiveRange(originalRange);
    }
    lock.releaseLock();
  }
}

function openBannedWordManagementSheet() {
  var sheet = ensureBannedWordSheet_();
  sheet.activate();
  sheet.getRange(&#39;A1&#39;).activate();
  return sheet.getName();
}

function getDefaultBannedList_() {
  return [
    &quot;MBTI&quot;, &quot;커리어넷&quot;, &quot;메이저맵&quot;, &quot;패들렛&quot;, &quot;띵커벨&quot;, &quot;알로&quot;,
    &quot;미리캔버스&quot;, &quot;망고보드&quot;, &quot;유튜버&quot;, &quot;유튜브&quot;, &quot;넷플릭스&quot;, &quot;왓챠&quot;,
    &quot;티빙&quot;, &quot;네이버 폼&quot;, &quot;구글 폼&quot;, &quot;OECD&quot;, &quot;UN&quot;, &quot;유네스코&quot;, &quot;WTO&quot;,
    &quot;WHO&quot;, &quot;그린피스&quot;, &quot;앰네스티&quot;, &quot;IMF&quot;, &quot;인스타그램&quot;, &quot;페이스북&quot;,
    &quot;카카오톡&quot;, &quot;네이버&quot;, &quot;구글&quot;, &quot;클래스 카드&quot;, &quot;구글 클래스룸&quot;, &quot;게더타운&quot;,
    &quot;제페토&quot;, &quot;아이패드&quot;, &quot;갤럭시 탭&quot;, &quot;비트코인&quot;, &quot;TED&quot;, &quot;반크&quot;, &quot;TOEIC&quot;,
    &quot;TOEFL&quot;, &quot;TEPS&quot;, &quot;대회&quot;, &quot;수상&quot;, &quot;한자능력검정&quot;
  ];
}

function getBannedWordList_() {
  var sheet = ensureBannedWordSheet_();
  var header = sheet.getRange(1, 1, 1, 2).getDisplayValues()[0];
  if (header.join(&#39;|&#39;) !== BANNED_WORD_HEADERS_.join(&#39;|&#39;)) {
    throw new Error(&#39;기재금지어관리 시트의 1행은 &quot;번호 | 기재 금지어&quot; 순서여야 합니다.&#39;);
  }

  var lastRow = sheet.getLastRow();
  if (lastRow &lt; 2) return [];

  var values = sheet.getRange(2, 1, lastRow - 1, 2).getDisplayValues();
  var seen = {};
  var list = [];

  values.forEach(function(row, index) {
    var rowNumber = index + 2;
    var number = String(row[0] || &#39;&#39;).trim();
    var keyword = String(row[1] || &#39;&#39;).trim();

    if (!number &amp;&amp; !keyword) return;
    if (!/^[1-9]\d*$/.test(number)) {
      throw new Error(&#39;기재금지어관리 시트 &#39; + rowNumber + &#39;행의 번호(A열)는 1 이상의 정수여야 합니다.&#39;);
    }
    if (!keyword) throw new Error(&#39;기재금지어관리 시트 &#39; + rowNumber + &#39;행의 기재 금지어(B열)를 입력해 주세요.&#39;);

    var duplicateKey = normalizeBannedWord_(keyword);
    if (seen[duplicateKey]) {
      throw new Error(&#39;기재금지어관리 시트 &#39; + rowNumber + &#39;행의 기재 금지어가 &#39; + seen[duplicateKey] + &#39;행과 중복됩니다: &#39; + keyword);
    }
    seen[duplicateKey] = rowNumber;
    list.push(keyword);
  });

  return list;
}

function runBannedWordCheck() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var sheetName = sheet.getName();
  var range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);

  var values = range.getValues();
  var startRow = range.getRow();
  var startCol = range.getColumn();
  
  var db = getBannedWordList_();
  if (db.length === 0) return [];
  db.sort(function(a, b) { return normalizeBannedWord_(b).length - normalizeBannedWord_(a).length; });
  var canonicalByNormalizedKeyword = {};
  db.forEach(function(keyword) { canonicalByNormalizedKeyword[normalizeBannedWord_(keyword)] = keyword; });
  var combinedPattern = new RegExp(&#39;(&#39; + db.map(buildWhitespaceInsensitivePattern_).join(&#39;|&#39;) + &#39;)&#39;, &#39;gi&#39;);

  var results = [];

  for (var i = 0; i &lt; values.length; i++) {
    for (var j = 0; j &lt; values[0].length; j++) {
      var original = values[i][j].toString();
      if (!original.trim()) continue;

      var hasIssue = false;
      var logs = [];

      var counts = {};
      var match;
      while ((match = combinedPattern.exec(original)) !== null) {
        var normalizedMatch = normalizeBannedWord_(match[0]);
        var keyword = canonicalByNormalizedKeyword[normalizedMatch];
        if (keyword) {
          hasIssue = true;
          counts[keyword] = (counts[keyword] || 0) + 1;
        }
      }

      Object.keys(counts).forEach(function(keyword) {
        logs.push({ k: keyword, count: counts[keyword] });
      });

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

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

function normalizeBannedWord_(value) {
  return String(value || &#39;&#39;).replace(/\s+/g, &#39;&#39;).toLocaleLowerCase();
}

function buildWhitespaceInsensitivePattern_(keyword) {
  var compact = normalizeBannedWord_(keyword);
  if (!compact) throw new Error(&#39;비어 있는 기재 금지어는 검사할 수 없습니다.&#39;);
  return compact.split(&#39;&#39;).map(function(character) {
    return escapeRegExp(character);
  }).join(&#39;[\\s\\u00A0]*&#39;);
}

// =================================================================
// [4] 로컬 검토 유틸리티
// =================================================================

function unifyToStraightQuotes() {
  var range = SpreadsheetApp.getActiveRange();
  var ui = SpreadsheetApp.getUi();
  if (!range) throw new Error(&#39;선택된 셀이 없습니다.&#39;);
  var response = ui.alert(&#39;따옴표 변환&#39;, &#39;선택 영역의 둥근 따옴표를 곧은 따옴표로 변환하시겠습니까?&#39;, ui.ButtonSet.YES_NO);
  if (response != ui.Button.YES) return;
  try {
    range.createTextFinder(&#39;[‘’]&#39;).useRegularExpression(true).matchEntireCell(false).replaceAllWith(&quot;&#39;&quot;);
    range.createTextFinder(&#39;[“”]&#39;).useRegularExpression(true).matchEntireCell(false).replaceAllWith(&#39;&quot;&#39;);
    SpreadsheetApp.flush();
    ui.alert(&#39;✅ 변환 완료&#39;);
  } catch (e) { ui.alert(&#39;오류: &#39; + e.toString()); }
}

// [신규] 선택 영역 스페이스바 실수(2칸 이상) 수정 함수
function fixDoubleSpaces() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);

  const values = range.getValues();
  let fixCount = 0; // 수정된 셀 개수 카운트

  const newValues = values.map(row =&gt; {
    return row.map(cell =&gt; {
      let str = String(cell);
      // 공백이 2번 이상 연속( /  +/)으로 있으면 1번으로 변경
      // 줄바꿈(\n)은 건드리지 않도록 일반 공백( )만 타겟팅
      if (/ {2,}/.test(str)) {
        fixCount++;
        return str.replace(/ {2,}/g, &#39; &#39;); 
      }
      return cell;
    });
  });

  if (fixCount &gt; 0) {
    range.setValues(newValues);
  }
  
  return fixCount;
}

// =================================================================
// [5] UI 상태 저장/불러오기
// =================================================================

function saveUiState(cardId, isExpanded) {
  var props = PropertiesService.getUserProperties();
  var state = JSON.parse(props.getProperty(&#39;UI_STATE&#39;) || &#39;{}&#39;);
  state[cardId] = isExpanded;
  props.setProperty(&#39;UI_STATE&#39;, JSON.stringify(state));
}

function getUiState() {
  var props = PropertiesService.getUserProperties();
  return JSON.parse(props.getProperty(&#39;UI_STATE&#39;) || &#39;{}&#39;);
}
</pre></section><section><h3>Sidebar.html</h3><pre>&lt;? var allowedViews = [&quot;sidebar&quot;, &quot;spellcheck&quot;, &quot;banned&quot;]; ?&gt;
&lt;? var activeView = allowedViews.indexOf(initialView) &gt;= 0 ? initialView : &quot;sidebar&quot;; ?&gt;
&lt;? if (activeView === &#39;sidebar&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;style&gt;
      * { box-sizing: border-box; }
      body {
        margin: 0;
        padding: 10px 15px 10px 2px;
        background: #f1f3f4;
        color: #202124;
        font-family: &#39;Noto Sans KR&#39;, 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=&quot;password&quot;] {
        width: 100%;
        padding: 10px;
        border: 2px solid #dadce0;
        border-radius: 6px;
        outline: none;
        font-family: inherit;
        font-size: 14px;
      }
      input[type=&quot;password&quot;]: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; }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div class=&quot;card&quot; id=&quot;card-api&quot;&gt;
      &lt;div class=&quot;card-header&quot; onclick=&quot;toggleCard(this)&quot;&gt;
        &lt;div class=&quot;header-title&quot;&gt;🔑 Bareun API 키 설정&lt;/div&gt;
        &lt;span class=&quot;material-icons toggle-icon&quot;&gt;expand_more&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class=&quot;card-body&quot;&gt;
        &lt;div class=&quot;sub-section&quot; id=&quot;sub-api-settings&quot;&gt;
          &lt;div class=&quot;sub-header&quot; onclick=&quot;toggleSub(this)&quot;&gt;
            &lt;span&gt;내 Bareun API 키&lt;/span&gt;
            &lt;span class=&quot;material-icons sub-icon&quot;&gt;expand_more&lt;/span&gt;
          &lt;/div&gt;
          &lt;div class=&quot;sub-body&quot;&gt;
            &lt;div id=&quot;api-loading&quot; class=&quot;api-status&quot;&gt;⌛ API 키 설정 확인 중...&lt;/div&gt;
            &lt;div id=&quot;api-settings&quot; class=&quot;hidden&quot;&gt;
              &lt;div id=&quot;apiStatusSummary&quot; class=&quot;api-status&quot;&gt;&lt;/div&gt;
              &lt;label class=&quot;input-label&quot; for=&quot;bareunKeyInput&quot;&gt;Bareun API 키&lt;/label&gt;
              &lt;input type=&quot;password&quot; id=&quot;bareunKeyInput&quot; autocomplete=&quot;off&quot; placeholder=&quot;본인이 발급받은 키를 입력하세요&quot;&gt;
              &lt;div class=&quot;key-help&quot;&gt;공용 키는 제공하지 않습니다. 키는 사용자별 설정에 저장되고 원문을 화면으로 다시 불러오지 않습니다.&lt;/div&gt;
              &lt;button class=&quot;btn-auth&quot; id=&quot;saveKeyBtn&quot; onclick=&quot;saveBareunKey()&quot;&gt;내 Bareun 키 저장&lt;/button&gt;
              &lt;button class=&quot;btn-secondary hidden&quot; id=&quot;clearKeyBtn&quot; onclick=&quot;resetBareunKey()&quot;&gt;저장된 Bareun 키 삭제&lt;/button&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;card expanded&quot; id=&quot;card-util&quot;&gt;
      &lt;div class=&quot;card-header&quot; onclick=&quot;toggleCard(this)&quot;&gt;
        &lt;div class=&quot;header-title&quot;&gt;📝 선택 영역 검사 및 정리&lt;/div&gt;
        &lt;span class=&quot;material-icons toggle-icon&quot;&gt;expand_more&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class=&quot;card-body&quot;&gt;
        &lt;button class=&quot;btn-warning&quot; id=&quot;checkBtn&quot; onclick=&quot;runSpellCheck()&quot;&gt;선택 영역 Bareun 맞춤법 검사 🔍&lt;/button&gt;
        &lt;div id=&quot;checkStatus&quot; class=&quot;status-box&quot;&gt;&lt;/div&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;runSpaceFix(this)&quot;&gt;선택 영역 연속 공백 정리&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;convertQuotes(this)&quot;&gt;선택 영역 따옴표 통일 (&#39; &quot;)&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;runBannedCheck()&quot;&gt;선택 영역 기재 금지어 검사&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;openBannedWordManagement()&quot;&gt;기재금지어관리 시트 열기&lt;/button&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let currentApiStatus = { hasBareun: false };

      function updateApiState(status) {
        currentApiStatus = { hasBareun: !!(status &amp;&amp; status.hasBareun) };

        const summary = document.getElementById(&#39;apiStatusSummary&#39;);
        const clearButton = document.getElementById(&#39;clearKeyBtn&#39;);
        const spellCheckButton = document.getElementById(&#39;checkBtn&#39;);
        const apiCard = document.getElementById(&#39;card-api&#39;);
        const apiSub = document.getElementById(&#39;sub-api-settings&#39;);

        summary.innerHTML = currentApiStatus.hasBareun
          ? &#39;&lt;div class=&quot;ok&quot;&gt;✅ Bareun 키 저장됨&lt;/div&gt;&#39;
          : &#39;&lt;div class=&quot;missing&quot;&gt;❌ 맞춤법 검사를 사용하려면 Bareun 키가 필요합니다.&lt;/div&gt;&#39;;
        clearButton.classList.toggle(&#39;hidden&#39;, !currentApiStatus.hasBareun);
        spellCheckButton.disabled = !currentApiStatus.hasBareun;
        spellCheckButton.title = currentApiStatus.hasBareun ? &#39;&#39; : &#39;Bareun API 키를 먼저 저장해 주세요.&#39;;

        apiCard.classList.toggle(&#39;expanded&#39;, !currentApiStatus.hasBareun);
        apiSub.classList.toggle(&#39;expanded&#39;, !currentApiStatus.hasBareun);
      }

      function toggleCard(header) {
        const card = header.parentElement;
        const isExpanded = card.classList.toggle(&#39;expanded&#39;);
        google.script.run.saveUiState(card.id, isExpanded);
      }

      function toggleSub(header) {
        const section = header.parentElement;
        const isExpanded = section.classList.toggle(&#39;expanded&#39;);
        google.script.run.saveUiState(section.id, isExpanded);
      }

      function saveBareunKey() {
        const button = document.getElementById(&#39;saveKeyBtn&#39;);
        const keyInput = document.getElementById(&#39;bareunKeyInput&#39;);
        const key = keyInput.value.trim();
        if (!key) {
          alert(&#39;Bareun API 키를 입력해 주세요.&#39;);
          return;
        }

        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;저장 중...&#39;;

        google.script.run
          .withSuccessHandler(function(status) {
            keyInput.value = &#39;&#39;;
            updateApiState(status);
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;✅ Bareun API 키를 사용자 설정에 저장했습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;Bareun API 키 저장 오류: &#39; + error);
          })
          .saveUserBareunKey(key);
      }

      function resetBareunKey() {
        if (!confirm(&#39;저장된 Bareun API 키를 삭제하시겠습니까?&#39;)) return;

        google.script.run
          .withSuccessHandler(function(status) {
            document.getElementById(&#39;bareunKeyInput&#39;).value = &#39;&#39;;
            updateApiState(status);
            alert(&#39;저장된 Bareun API 키를 삭제했습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            alert(&#39;Bareun API 키 삭제 오류: &#39; + error);
          })
          .clearUserBareunKey();
      }

      function runSpellCheck() {
        if (!currentApiStatus.hasBareun) {
          alert(&#39;Bareun API 키를 먼저 저장해 주세요.&#39;);
          return;
        }
        document.getElementById(&#39;checkStatus&#39;).innerText = &#39;맞춤법 검사 창을 여는 중...&#39;;
        google.script.run
          .withSuccessHandler(function() {
            document.getElementById(&#39;checkStatus&#39;).innerText = &#39;&#39;;
          })
          .withFailureHandler(function(error) {
            document.getElementById(&#39;checkStatus&#39;).innerText = &#39;&#39;;
            alert(&#39;오류 발생: &#39; + error);
          })
          .openSpellCheckViewer();
      }

      function runSpaceFix(button) {
        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;정리 중...&#39;;
        google.script.run
          .withSuccessHandler(function(count) {
            button.disabled = false;
            button.innerText = originalText;
            alert(count ? &#39;✅ 총 &#39; + count + &#39;개 셀의 연속 공백을 정리했습니다.&#39; : &#39;수정할 연속 공백이 없습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;오류 발생: &#39; + error);
          })
          .fixDoubleSpaces();
      }

      function convertQuotes(button) {
        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;변환 중...&#39;;
        google.script.run
          .withSuccessHandler(function() {
            button.disabled = false;
            button.innerText = originalText;
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;오류 발생: &#39; + error);
          })
          .unifyToStraightQuotes();
      }

      function runBannedCheck() {
        google.script.run
          .withFailureHandler(function(error) { alert(&#39;오류 발생: &#39; + error); })
          .openBannedWordViewer();
      }

      function openBannedWordManagement() {
        google.script.run
          .withFailureHandler(function(error) { alert(&#39;오류 발생: &#39; + error); })
          .openBannedWordManagementSheet();
      }

      window.onload = function() {
        google.script.run
          .withSuccessHandler(function(status) {
            document.getElementById(&#39;api-loading&#39;).classList.add(&#39;hidden&#39;);
            document.getElementById(&#39;api-settings&#39;).classList.remove(&#39;hidden&#39;);
            updateApiState(status);

            google.script.run.withSuccessHandler(function(states) {
              Object.keys(states || {}).forEach(function(id) {
                const element = document.getElementById(id);
                if (id === &#39;card-api&#39; || id === &#39;sub-api-settings&#39;) return;
                if (element &amp;&amp; states[id]) element.classList.add(&#39;expanded&#39;);
              });
              updateApiState(status);
            }).getUiState();
          })
          .withFailureHandler(function(error) {
            document.getElementById(&#39;api-loading&#39;).classList.add(&#39;hidden&#39;);
            document.getElementById(&#39;api-settings&#39;).classList.remove(&#39;hidden&#39;);
            updateApiState({ hasBareun: false });
            document.getElementById(&#39;apiStatusSummary&#39;).textContent = &#39;설정 확인 오류: &#39; + error;
          })
          .checkUserBareunStatus();
      };
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } else if (activeView === &#39;spellcheck&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch.js&quot;&gt;&lt;/script&gt;
    &lt;style&gt;
      body { padding: 20px; font-family: &#39;Noto Sans KR&#39;, 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; }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;

    &lt;div class=&quot;header-area&quot;&gt;
      &lt;div class=&quot;title&quot;&gt;&lt;span class=&quot;material-icons&quot;&gt;fact_check&lt;/span&gt; 맞춤법 검사 및 수정&lt;/div&gt;
      &lt;div style=&quot;font-size:12px; color:#5f6368; display:flex; gap:15px;&quot;&gt;
        &lt;div&gt;&lt;span style=&quot;background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;&quot;&gt;원본 오류&lt;/span&gt;&lt;/div&gt;
        &lt;div&gt;&lt;span style=&quot;background:#e6f4ea; color:#188038; border-bottom:2px solid #188038; padding:0 4px; font-weight:500;&quot;&gt;수정 제안&lt;/span&gt;&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div id=&quot;mainContent&quot; class=&quot;content-area&quot;&gt;
      &lt;div id=&quot;loaderOverlay&quot; class=&quot;loader-overlay&quot;&gt;
        &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
        &lt;div id=&quot;loadingText&quot;&gt;데이터를 불러오는 중입니다...&lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;cardsContainer&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;footer-bar&quot;&gt;
      &lt;button class=&quot;btn btn-cancel&quot; onclick=&quot;google.script.host.close()&quot;&gt;닫기&lt;/button&gt;
      &lt;button class=&quot;btn btn-apply&quot; id=&quot;btnApply&quot; onclick=&quot;applyAll()&quot; disabled&gt;적용하기 (0건)&lt;/button&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let itemData = []; 
      let isFirstRender = true; 

      window.onload = function() {
        google.script.run
          .withSuccessHandler(processData)
          .withFailureHandler(function(err) {
             document.getElementById(&#39;loaderOverlay&#39;).innerHTML = 
               `&lt;div class=&quot;error-msg&quot;&gt;❌ 초기화 오류&lt;br&gt;${err}&lt;br&gt;선택된 셀이 있는지 확인해주세요.&lt;/div&gt;`;
          })
          .getSelectedDataForSpellCheck();
      };

      function processData(rows) {
        if (rows.length === 0) {
          document.getElementById(&#39;loaderOverlay&#39;).innerHTML = &#39;&lt;div class=&quot;error-msg&quot;&gt;선택된 셀이 없거나 빈 셀입니다.&lt;/div&gt;&#39;;
          return;
        }

        document.getElementById(&#39;loadingText&#39;).innerText = `총 ${rows.length}개 항목 분석 중...`;
        const container = document.getElementById(&#39;cardsContainer&#39;);
        container.innerHTML = &quot;&quot;; 
        
        let processedCount = 0;
        const total = rows.length;

        rows.forEach((row, index) =&gt; {
          const div = document.createElement(&#39;div&#39;);
          div.id = `item-${index}`;
          div.className = &#39;check-item&#39;;
          div.innerHTML = `
            &lt;div class=&quot;item-header&quot;&gt;&lt;span&gt;행 ${row.row} (대기 중...)&lt;/span&gt;&lt;/div&gt;
            &lt;div class=&quot;split-view&quot;&gt;&lt;div class=&quot;pane pane-left&quot;&gt;...&lt;/div&gt;&lt;div class=&quot;pane pane-right&quot;&gt;...&lt;/div&gt;&lt;/div&gt;
          `;
          container.appendChild(div);

          google.script.run
            .withSuccessHandler((res) =&gt; {
              renderResult(index, row, res);
              processedCount++;
              updateApplyButton(processedCount, total);
            })
            .withFailureHandler((err) =&gt; {
              renderError(index, row, err);
              processedCount++;
              updateApplyButton(processedCount, total);
            })
            .getBareunCorrection(row.original);
        });
      }

      function renderError(index, rowData, errorMsg) {
        if (isFirstRender) {
          document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
          isFirstRender = false;
        }
        const itemDiv = document.getElementById(`item-${index}`);
        if (!itemDiv) return;
        itemDiv.innerHTML = `&lt;div style=&quot;padding:20px; color:red;&quot;&gt;❌ 분석 실패: ${errorMsg}&lt;/div&gt;`;
      }

      function renderResult(index, rowData, apiResult) {
        if (isFirstRender) {
          document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
          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 = &quot;&quot;;
        const analysisList = [];
        if (apiResult.rawBlocks &amp;&amp; apiResult.rawBlocks.length &gt; 0) {
          traverseBlocks(apiResult.rawBlocks, apiResult.helps, analysisList);
        }

        if (analysisList.length &gt; 0) {
          const listItems = analysisList.map(item =&gt; `
            &lt;li class=&quot;analysis-row&quot;&gt;
              &lt;span class=&quot;err-word&quot;&gt;${item.origin}&lt;/span&gt;
              &lt;span class=&quot;arrow&quot;&gt;→&lt;/span&gt;
              &lt;span class=&quot;fix-word&quot;&gt;${item.revised}&lt;/span&gt;
              &lt;span class=&quot;fix-reason&quot;&gt;${item.msg}&lt;/span&gt;
            &lt;/li&gt;
          `).join(&quot;&quot;);

          helpListHtml = `
            &lt;div class=&quot;analysis-box&quot;&gt;
              &lt;div class=&quot;analysis-title&quot;&gt;&lt;span class=&quot;material-icons&quot; style=&quot;font-size:14px;&quot;&gt;info&lt;/span&gt; 상세 수정 내역 (${analysisList.length}건)&lt;/div&gt;
              &lt;ul class=&quot;analysis-list&quot;&gt;${listItems}&lt;/ul&gt;
            &lt;/div&gt;
          `;
        }

        itemDiv.innerHTML = `
          &lt;div class=&quot;item-header&quot;&gt;
            &lt;span&gt;행 ${rowData.row}&lt;/span&gt;
            &lt;span style=&quot;color:#1a73e8;&quot;&gt;분석 완료&lt;/span&gt;
          &lt;/div&gt;
          &lt;div class=&quot;split-view&quot;&gt;
            &lt;div class=&quot;pane pane-left&quot;&gt;${diff.left}&lt;/div&gt;
            &lt;div class=&quot;pane pane-right&quot; contenteditable=&quot;true&quot; spellcheck=&quot;false&quot; id=&quot;edit-${index}&quot; oninput=&quot;updateBytes(${index})&quot;&gt;${diff.right}&lt;/div&gt;
          &lt;/div&gt;
          ${helpListHtml}
          &lt;div class=&quot;byte-info&quot;&gt;
            &lt;div class=&quot;byte-box&quot; style=&quot;border-right:1px solid #eee;&quot;&gt;원본: ${getByteLength(rowData.original)} byte&lt;/div&gt;
            &lt;div class=&quot;byte-box&quot;&gt;수정본: &lt;span id=&quot;byte-${index}&quot; class=&quot;byte-val&quot;&gt;${getByteLength(apiResult.revised)}&lt;/span&gt; byte&lt;/div&gt;
          &lt;/div&gt;
        `;
        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 &amp;&amp; block.nested.length &gt; 0) {
            traverseBlocks(block.nested, helps, resultsArray);
          } else {
            const origin = block.origin.content || &quot;&quot;;
            const revised = block.revised || &quot;&quot;;
            if (origin === revised) return;
            let msg = &quot;교정&quot;;
            if (block.revisions &amp;&amp; block.revisions.length &gt; 0) {
              const helpId = block.revisions[0].helpId || block.revisions[0].help_id;
              if (helps &amp;&amp; !helps[helpId]) return;
              if (helpId &amp;&amp; helps &amp;&amp; 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 === &#39;undefined&#39;) {
           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 = &quot;&quot;;
          for (var i = 0; i &lt; 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 = &quot;&quot;;
        let rightHtml = &quot;&quot;;
        
        diffs.forEach(function(part) {
          var op = part[0]; 
          var chars = part[1];
          var text = &quot;&quot;;
          
          // 유니코드를 다시 원래 단어로 복원
          for (var i = 0; i &lt; chars.length; i++) {
            text += tokenArr[chars.charCodeAt(i)];
          }
          
          if (op === 0) {
            leftHtml += text; rightHtml += text;
          } else if (op === -1) {
            leftHtml += `&lt;span class=&quot;diff-del&quot;&gt;${text}&lt;/span&gt;`;
          } else if (op === 1) {
            rightHtml += `&lt;span class=&quot;diff-add&quot;&gt;${text}&lt;/span&gt;`;
          }
        });
        
        return { left: leftHtml, right: rightHtml };
      }

      function getByteLength(s) {
        let b = 0; if (!s) return 0;
        for (let i = 0; i &lt; s.length; i++) {
          const c = s.charCodeAt(i);
          if (c === 10) b += 2; else if (c &gt;&gt; 11) b += 3; else if (c &gt;&gt; 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(&#39;btnApply&#39;);
        btn.innerText = `적용하기 (${current} / ${total})`;
        if (current === total) btn.disabled = false;
      }

      function applyAll() {
        if (!confirm(&quot;검사된 내용으로 원본 데이터를 덮어쓰시겠습니까?\n(되돌릴 수 없습니다)&quot;)) return;
        const updates = [];
        itemData.forEach((item, index) =&gt; {
          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(&#39;btnApply&#39;);
        btn.disabled = true; btn.innerText = &quot;저장 중...&quot;;
        google.script.run.withSuccessHandler((count) =&gt; {
          alert(`✅ 총 ${count}건이 수정되었습니다.`);
          google.script.host.close();
        }).applySpellCheckResults(updates);
      }
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } else if (activeView === &#39;banned&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;style&gt;
      body { padding: 20px; font-family: &#39;Noto Sans KR&#39;, 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: &quot;•&quot;; 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); } }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;

    &lt;div class=&quot;header-area&quot;&gt;
      &lt;div class=&quot;title&quot;&gt;&lt;span class=&quot;material-icons&quot;&gt;block&lt;/span&gt; 기재 금지어 검사&lt;/div&gt;
      &lt;div id=&quot;checkLegend&quot; style=&quot;font-size:12px; color:#5f6368; display:flex; gap:15px;&quot;&gt;
        &lt;div&gt;&lt;span style=&quot;background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;&quot;&gt;금지어 발견&lt;/span&gt;&lt;/div&gt;
        &lt;div&gt;오른쪽 수정안에서 문맥에 맞게 직접 고쳐 주세요.&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div id=&quot;checkView&quot; class=&quot;content-area&quot;&gt;
      &lt;div id=&quot;loaderOverlay&quot; class=&quot;loader-overlay&quot;&gt;
        &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
        &lt;div id=&quot;loadingText&quot;&gt;데이터를 분석 중입니다...&lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;cardsContainer&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;footer-bar&quot; id=&quot;footerCheck&quot;&gt;
      &lt;button class=&quot;btn btn-cancel&quot; onclick=&quot;google.script.host.close()&quot;&gt;닫기&lt;/button&gt;
      &lt;button class=&quot;btn btn-apply&quot; id=&quot;btnApply&quot; onclick=&quot;applyAll()&quot; disabled&gt;적용하기 (0건)&lt;/button&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let itemData = []; 
      let renderQueue = [];
      const CHUNK_SIZE = 20;

      window.onload = function() {
        runCheck();
      };

      function runCheck() {
        document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;flex&#39;;
        document.getElementById(&#39;loadingText&#39;).innerText = &quot;데이터를 분석 중입니다...&quot;;
        document.getElementById(&#39;cardsContainer&#39;).innerHTML = &quot;&quot;;
        
        google.script.run
          .withSuccessHandler(startRenderSequence)
          .withFailureHandler(err =&gt; alert(&quot;오류: &quot; + err))
          .runBannedWordCheck();
      }

      function startRenderSequence(rows) {
        document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
        const container = document.getElementById(&#39;cardsContainer&#39;);
        
        if (!rows || rows.length === 0) {
          container.innerHTML = &#39;&lt;div style=&quot;text-align:center; padding:50px; color:#5f6368;&quot;&gt;금지어가 발견되지 않았습니다. 🎉&lt;/div&gt;&#39;;
          document.getElementById(&#39;btnApply&#39;).disabled = true;
          return;
        }

        itemData = rows; 
        renderQueue = [...rows]; 
        processRenderQueue(); 

        document.getElementById(&#39;btnApply&#39;).innerText = `적용하기 (${rows.length}건)`;
        document.getElementById(&#39;btnApply&#39;).disabled = false;
      }

      function processRenderQueue() {
        if (renderQueue.length === 0) return;

        const container = document.getElementById(&#39;cardsContainer&#39;);
        const chunk = renderQueue.splice(0, CHUNK_SIZE);
        
        chunk.forEach((row) =&gt; {
          const originalIndex = itemData.findIndex(r =&gt; r.row === row.row &amp;&amp; r.col === row.col);
          let logHtml = &quot;&quot;;
          if (row.logs &amp;&amp; row.logs.length &gt; 0) {
            const listItems = row.logs.map(log =&gt; {
               const count = log.count &gt; 1 ? ` (${log.count}회)` : &#39;&#39;;
               return `&lt;li class=&quot;analysis-row&quot;&gt;&lt;span&gt;&lt;span class=&quot;err-word&quot;&gt;${escapeHtml(log.k)}&lt;/span&gt;${count} 기재금지어가 발견되었습니다. 오른쪽 수정안에서 문맥에 맞게 직접 고쳐 주세요.&lt;/span&gt;&lt;/li&gt;`;
            }).join(&quot;&quot;);
            
            logHtml = `
              &lt;div class=&quot;analysis-box&quot;&gt;
                &lt;ul class=&quot;analysis-list&quot;&gt;${listItems}&lt;/ul&gt;
              &lt;/div&gt;
            `;
          }

          const div = document.createElement(&#39;div&#39;);
          div.className = &#39;check-item&#39;;
          div.innerHTML = `
            &lt;div class=&quot;item-header&quot;&gt;
              &lt;span&gt;행 ${row.row}&lt;/span&gt;
              &lt;span style=&quot;color:#d93025; font-weight:bold;&quot;&gt;금지어 발견&lt;/span&gt;
            &lt;/div&gt;
            &lt;div class=&quot;split-view&quot;&gt;
              &lt;div class=&quot;pane pane-left&quot;&gt;${highlightBannedWords(row.original, row.logs)}&lt;/div&gt;
              &lt;div class=&quot;pane pane-right&quot; contenteditable=&quot;true&quot; spellcheck=&quot;false&quot; id=&quot;edit-${originalIndex}&quot;&gt;&lt;/div&gt;
            &lt;/div&gt;
            ${logHtml}
          `;
          container.appendChild(div);
          // 원문을 그대로 넣습니다. 기재금지어 검사는 자동 대체·삭제를 전혀 하지 않습니다.
          document.getElementById(`edit-${originalIndex}`).textContent = row.original;
        });

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

      function escapeHtml(value) {
        return String(value)
          .replace(/&amp;/g, &#39;&amp;amp;&#39;)
          .replace(/&lt;/g, &#39;&amp;lt;&#39;)
          .replace(/&gt;/g, &#39;&amp;gt;&#39;)
          .replace(/&quot;/g, &#39;&amp;quot;&#39;)
          .replace(/&#39;/g, &#39;&amp;#39;&#39;);
      }

      function escapeRegExp(value) {
        return String(value).replace(/[.*+?^${}()|[\]\\]/g, &#39;\\$&amp;&#39;);
      }

      function highlightBannedWords(text, logs) {
        const keywords = (logs || []).map(log =&gt; log.k).filter(Boolean);
        if (keywords.length === 0) return escapeHtml(text);
        const fragments = keywords
          .sort((a, b) =&gt; compactBannedWord(b).length - compactBannedWord(a).length)
          .map(buildWhitespaceInsensitivePattern);
        const pattern = new RegExp(`(${fragments.join(&#39;|&#39;)})`, &#39;gi&#39;);
        return String(text).split(pattern).map((part, index) =&gt; {
          const safeText = escapeHtml(part);
          return index % 2 === 1 ? `&lt;span class=&quot;diff-del&quot;&gt;${safeText}&lt;/span&gt;` : safeText;
        }).join(&#39;&#39;);
      }

      function compactBannedWord(value) {
        return String(value || &#39;&#39;).replace(/\s+/g, &#39;&#39;).toLocaleLowerCase();
      }

      function buildWhitespaceInsensitivePattern(keyword) {
        return compactBannedWord(keyword)
          .split(&#39;&#39;)
          .map(escapeRegExp)
          .join(&#39;[\\s\\u00A0]*&#39;);
      }

      function applyAll() {
        if (!confirm(&quot;수정된 내용으로 원본을 덮어쓰시겠습니까?&quot;)) return;
        
        const updates = [];
        itemData.forEach((item, index) =&gt; {
          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) =&gt; {
           alert(`${c}건 수정 완료!`);
           google.script.host.close();
        }).applySpellCheckResults(updates);
      }

    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } ?&gt;
</pre></section></section><section><h2>(5) 빅데이터 분석 기반 융합수업</h2><h3>교안 및 학습자료</h3><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F1785378344653_27315543_%EC%A0%84%EB%B6%81_%EC%83%81%EA%B6%8C%EC%A0%95%EB%B3%B4.xlsx?alt=media&amp;token=cbe78dcb-73f3-4d8b-84bb-ab2da72b81b5">전북_상권정보.xlsx</a></li></ul></section><section><h2>(6) 설문조사, 지도안 작성</h2><h3>관련 링크</h3><ul><li><a href="https://padlet.com/kes960212/2-_-2-7yrg67k1n0tge2eb">지도안 제출 링크 → 2회차 수업 설계안 제출</a></li><li><a href="https://www.survey.co.kr/?ACCESS_KEY=6c6923abcaf64c4ba2b3&amp;grpid=&amp;UID=&amp;TYPE=1">사후 역량 진단</a></li><li><a href="https://www.survey.co.kr/?ACCESS_KEY=a94cf69c21804c59b403&amp;grpid=&amp;AREA=5">만족도 조사</a></li></ul></section>]]></description></item><item><title>2026년 인공지능 활용 선도교사 연수 (충북 청주 - 7. 26.)</title><link>https://민기.한국/64</link><guid isPermaLink="true">https://민기.한국/64</guid><pubDate>Sat, 25 Jul 2026 14:58:29 GMT</pubDate><description><![CDATA[<h1>2026년 인공지능 활용 선도교사 연수 (충북 청주 - 7. 26.)</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-07-26</dd><dt>장소</dt><dd>청주 오스코</dd></dl><section><h2>(1) 클로바노트 음성 전사</h2><h3>관련 링크</h3><ul><li><a href="https://clovanote.naver.com/w/GL2ZnyzYqzF59Bi2XCsw/home">네이버 클로바노트</a></li><li><a href="https://drive.google.com/file/d/1bTxR-DyK3I4i1ZRCdyVb_lWFEobrtOKe/view">실습용 녹음 파일</a></li></ul></section><section><h2>(2) 유튜브 댓글 수집</h2><p>아래 블로그의 설명에 따라 Youtube Data API 키를 발급합니다. 어려운 경우 최후의 수단으로 다음 연수용 API KEY를 사용할 수 있습니다. 이 KEY는 오늘 이후 폐기 처리되므로 유튜브 댓글 수집기가 더이상 작동하지 않습니다. AIzaSyDETqF5gzbFh3DG4U0N-rjT1yK2asM-1X4</p><section><h3>Code.gs</h3><pre>const YT_CFG = Object.freeze({
  API_BASE: &#39;https://www.googleapis.com/youtube/v3&#39;,

  API_KEY_PROP: &#39;YT_API_KEY&#39;,
  STATE_PROP: &#39;YT_COMMENT_COLLECTOR_STATE&#39;,
  LAST_RESULT_PROP: &#39;YT_COMMENT_COLLECTOR_LAST_RESULT&#39;,
  CANCEL_REQUEST_PROP: &#39;YT_COMMENT_COLLECTOR_CANCEL_REQUEST&#39;,

  OUTPUT_SHEET_PREFIX: &#39;시트&#39;,
  INTERNAL_SHEET: &#39;_댓글수집_내부&#39;,
  LEGACY_LOG_SHEET: &#39;수집로그&#39;,

  CONTINUE_HANDLER: &#39;continueCommentCollection&#39;,
  STRUCTURED_ERROR_PREFIX: &#39;YT_STRUCTURED_ERROR::&#39;,
  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: [
    &#39;번호&#39;,
    &#39;채널명&#39;,
    &#39;영상 제목&#39;,
    &#39;댓글 작성자&#39;,
    &#39;댓글 내용&#39;,
    &#39;좋아요 수&#39;,
    &#39;작성 시각&#39;,
    &#39;글자수&#39;
  ],

  // 숨김 내부 시트의 열 구조입니다.
  INTERNAL_HEADERS: [
    &#39;comment_id&#39;,
    &#39;parent_comment_id&#39;,
    &#39;display_number&#39;,
    &#39;comment_type&#39;,
    &#39;reply_status&#39;,
    &#39;next_reply_index&#39;,
    &#39;total_reply_count&#39;,
    &#39;reply_page_token&#39;
  ],

  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(&#39;YouTube 댓글 수집&#39;)
    .addItem(&#39;사이드바 열기&#39;, &#39;showYouTubeCommentSidebar&#39;)
    .addSeparator()
    .addItem(&#39;중단 지점부터 실행&#39;, &#39;continueCommentCollection&#39;)
    .addItem(&#39;진행 중인 수집 취소&#39;, &#39;cancelCommentCollection&#39;)
    .addToUi();
}


/**
 * 오른쪽 사이드바를 엽니다.
 */
function showYouTubeCommentSidebar() {
  const html = HtmlService
    .createHtmlOutputFromFile(&#39;Sidebar&#39;)
    .setTitle(&#39;YouTube 댓글 수집기&#39;);

  SpreadsheetApp.getUi().showSidebar(html);
}


/**
 * 사이드바 초기 상태를 반환합니다.
 */
function getSidebarState() {
  const props = getUserProperties_();
  const apiKey = props.getProperty(YT_CFG.API_KEY_PROP) || &#39;&#39;;
  const activeState = loadState_();
  const lastResult = loadLastResult_();
  const cancelRequested =
    props.getProperty(YT_CFG.CANCEL_REQUEST_PROP) === &#39;true&#39;;

  const progress = activeState
    ? buildProgressForClient_(activeState, &#39;RUNNING&#39;)
    : lastResult
      ? buildProgressForClient_(lastResult, lastResult.status || &#39;COMPLETED&#39;)
      : null;

  if (progress &amp;&amp; activeState &amp;&amp; cancelRequested) {
    progress.stage = &#39;취소 처리 중&#39;;
    progress.message = &#39;현재 실행 구간이 끝나는 즉시 안전하게 중단됩니다.&#39;;
  }

  return {
    apiKeyConfigured: Boolean(apiKey),
    apiKeyMask: apiKey ? maskApiKey_(apiKey) : &#39;&#39;,
    running: Boolean(activeState),
    cancelRequested: cancelRequested,
    progress: progress
  };
}


/**
 * API 키를 실제 YouTube Data API 요청으로 검증한 뒤 사용자별 속성에 저장합니다.
 */
function saveApiKey(apiKey) {
  const cleanedKey = String(apiKey || &#39;&#39;).trim();

  if (!cleanedKey) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_REQUIRED&#39;,
      &#39;API 키를 입력해야 합니다.&#39;,
      &#39;Google Cloud에서 발급한 YouTube Data API 키가 입력되지 않았습니다.&#39;,
      [&#39;API 키 입력란이 비어 있습니다.&#39;],
      [
        &#39;Google Cloud Console에서 API 키를 발급합니다.&#39;,
        &#39;API 키 전체를 사이드바 입력란에 붙여 넣습니다.&#39;,
        &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 입력&#39;
      }
    ));
  }

  if (cleanedKey.length &lt; 20) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_FORMAT_INVALID&#39;,
      &#39;API 키 형식을 확인해야 합니다.&#39;,
      &#39;입력한 문자열이 일반적인 Google API 키 길이보다 짧습니다.&#39;,
      [
        &#39;API 키 일부만 복사했을 수 있습니다.&#39;,
        &#39;프로젝트 ID나 다른 값을 입력했을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;키 유형이 ‘API 키’인지 확인합니다.&#39;,
        &#39;API 키 값을 처음부터 끝까지 다시 복사하여 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 다시 입력&#39;
      }
    ));
  }

  validateApiKey_(cleanedKey);

  const props = getUserProperties_();
  props.setProperty(YT_CFG.API_KEY_PROP, cleanedKey);

  // 이전 API 키 오류 안내가 새 키 저장 뒤 다시 나타나지 않도록 정리합니다.
  const previous = loadLastResult_();
  if (previous &amp;&amp; previous.status === &#39;ERROR&#39;) {
    props.deleteProperty(YT_CFG.LAST_RESULT_PROP);
  }

  return getSidebarState();
}


/**
 * 저장된 API 키를 삭제합니다.
 */
function deleteApiKey() {
  if (loadState_()) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_DELETE_BLOCKED&#39;,
      &#39;수집 중에는 API 키를 삭제할 수 없습니다.&#39;,
      &#39;현재 댓글 수집 작업이 저장된 API 키를 사용하고 있습니다.&#39;,
      [&#39;진행 중인 작업이 존재합니다.&#39;],
      [
        &#39;현재 수집을 완료하거나 취소합니다.&#39;,
        &#39;수집 상태가 종료된 뒤 API 키를 삭제합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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_(
        &#39;SPREADSHEET_NOT_FOUND&#39;,
        &#39;댓글을 저장할 스프레드시트를 찾지 못했습니다.&#39;,
        &#39;현재 Apps Script가 스프레드시트와 연결된 상태인지 확인해야 합니다.&#39;,
        [
          &#39;독립 실행형 Apps Script 프로젝트에서 코드를 실행했을 수 있습니다.&#39;,
          &#39;스프레드시트가 닫히거나 접근 권한이 변경되었을 수 있습니다.&#39;
        ],
        [
          &#39;댓글을 저장할 Google 스프레드시트를 엽니다.&#39;,
          &#39;‘확장 프로그램 → Apps Script’에서 이 코드를 실행합니다.&#39;,
          &#39;스프레드시트를 새로고침하고 사이드바를 다시 엽니다.&#39;
        ],
        { actionLabel: &#39;확인&#39; }
      ));
    }

    // 영상과 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: &#39;RUNNING&#39;,
      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: &#39;&#39;,
      threadDone: false,

      currentReplyParentId: &#39;&#39;,
      nextReplyPageToken: &#39;&#39;,

      topCount: 0,
      replyCount: 0,
      threadPages: 0,
      replyPages: 0,
      apiCalls: 1,

      startedAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      message: outputSheet.getName() + &#39;에서 수집을 시작했습니다.&#39;,
      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 &lt; 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 || &#39;&#39;;
        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 = &#39;실행 시간을 고려하여 진행 상태를 저장했습니다.&#39;;
    saveState_(state);
    scheduleContinuation_();

  } catch (error) {
    deleteContinuationTriggers_();

    const errorInfo = normalizeErrorInfo_(error, &#39;댓글 수집&#39;);

    if (errorInfo.code === &#39;API_KEY_INVALID&#39;) {
      getUserProperties_().deleteProperty(YT_CFG.API_KEY_PROP);
    }

    if (commentsSheet) {
      try {
        finalizeCommentsSheet_(commentsSheet);
      } catch (formatError) {
        console.error(formatError);
      }
    }

    if (state) {
      state.status = &#39;ERROR&#39;;
      state.updatedAt = new Date().toISOString();
      state.message = errorInfo.summary;
      state.error = errorInfo;
      saveLastResult_(state, &#39;ERROR&#39;, 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, &#39;true&#39;);
  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 &lt;= 0) {
    state.threadDone = true;
    state.nextThreadPageToken = &#39;&#39;;
    return;
  }

  const params = {
    part: state.includeReplies ? &#39;snippet,replies&#39; : &#39;snippet&#39;,
    videoId: state.videoId,
    maxResults: Math.min(100, remaining),
    textFormat: &#39;plainText&#39;
  };

  if (state.nextThreadPageToken) {
    params.pageToken = state.nextThreadPageToken;
  }

  const data = youtubeGet_(&#39;commentThreads&#39;, 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 &gt;= 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 = &#39;DONE&#39;;
    let nextReplyIndex = 1;

    commentRows.push(
      commentToOutputRow_(state, topComment, topNumber)
    );

    batchIds.add(topCommentId);
    topAdded += 1;

    const inlineReplies =
      thread.replies &amp;&amp; Array.isArray(thread.replies.comments)
        ? thread.replies.comments
        : [];

    if (state.includeReplies &amp;&amp; totalReplyCount &gt; 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 + &#39;_&#39; + nextReplyIndex;
          nextReplyIndex += 1;

          commentRows.push(
            commentToOutputRow_(state, reply, displayNumber)
          );

          internalRows.push(
            buildInternalRow_(
              replyId,
              topCommentId,
              displayNumber,
              &#39;reply&#39;,
              &#39;&#39;,
              &#39;&#39;,
              &#39;&#39;,
              &#39;&#39;
            )
          );

          batchIds.add(replyId);
          replyAdded += 1;
        });
      } else {
        // commentThreads.list의 replies에는 전체 답글이 보장되지 않으므로
        // comments.list로 전체 답글을 다시 가져옵니다.
        replyStatus = &#39;PENDING&#39;;
        nextReplyIndex = 1;
      }
    }

    internalRows.push(
      buildInternalRow_(
        topCommentId,
        &#39;&#39;,
        topNumber,
        &#39;topLevel&#39;,
        replyStatus,
        nextReplyIndex,
        totalReplyCount,
        &#39;&#39;
      )
    );
  });

  appendCollectedRecords_(
    commentsSheet,
    internalSheet,
    commentRows,
    internalRows
  );

  batchIds.forEach(function(id) {
    existingIds.add(id);
  });

  state.topCount += topAdded;
  state.replyCount += replyAdded;

  const reachedTarget = state.topCount &gt;= state.targetTopLevelCount;
  const hasNextPage = Boolean(data.nextPageToken);

  state.nextThreadPageToken =
    !reachedTarget &amp;&amp; hasNextPage
      ? data.nextPageToken
      : &#39;&#39;;

  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 = &#39;&#39;;
    state.nextReplyPageToken = &#39;&#39;;
    return;
  }

  const params = {
    part: &#39;snippet&#39;,
    parentId: state.currentReplyParentId,
    maxResults: 100,
    textFormat: &#39;plainText&#39;
  };

  const pageToken =
    state.nextReplyPageToken || topRecord.replyPageToken || &#39;&#39;;

  if (pageToken) {
    params.pageToken = pageToken;
  }

  const data = youtubeGet_(&#39;comments&#39;, 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) + &#39;_&#39; + nextReplyIndex;

    nextReplyIndex += 1;

    commentRows.push(
      commentToOutputRow_(state, reply, displayNumber)
    );

    internalRows.push(
      buildInternalRow_(
        replyId,
        state.currentReplyParentId,
        displayNumber,
        &#39;reply&#39;,
        &#39;&#39;,
        &#39;&#39;,
        &#39;&#39;,
        &#39;&#39;
      )
    );

    batchIds.add(replyId);
    replyAdded += 1;
  });

  appendCollectedRecords_(
    commentsSheet,
    internalSheet,
    commentRows,
    internalRows
  );

  batchIds.forEach(function(id) {
    existingIds.add(id);
  });

  state.replyCount += replyAdded;

  const nextPageToken = data.nextPageToken || &#39;&#39;;
  const nextStatus = nextPageToken ? &#39;IN_PROGRESS&#39; : &#39;DONE&#39;;

  updateTopReplyProgress_(
    internalSheet,
    topRecord.rowNumber,
    nextStatus,
    nextReplyIndex,
    nextPageToken
  );

  if (nextPageToken) {
    state.nextReplyPageToken = nextPageToken;
  } else {
    state.currentReplyParentId = &#39;&#39;;
    state.nextReplyPageToken = &#39;&#39;;
  }

  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(&#39;IN_PROGRESS&#39;)
    .matchEntireCell(true)
    .useRegularExpression(false)
    .findNext();

  if (!found) {
    found = statusRange
      .createTextFinder(&#39;PENDING&#39;)
      .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] === &#39;PENDING&#39;) {
    internalSheet
      .getRange(rowNumber, YT_CFG.INTERNAL_COL.REPLY_STATUS)
      .setValue(&#39;IN_PROGRESS&#39;);
  }

  return {
    rowNumber: rowNumber,
    commentId: String(values[YT_CFG.INTERNAL_COL.COMMENT_ID - 1] || &#39;&#39;),
    displayNumber: String(
      values[YT_CFG.INTERNAL_COL.DISPLAY_NUMBER - 1] || &#39;&#39;
    ),
    nextReplyIndex: Number(
      values[YT_CFG.INTERNAL_COL.NEXT_REPLY_INDEX - 1] || 1
    ),
    pageToken: String(
      values[YT_CFG.INTERNAL_COL.REPLY_PAGE_TOKEN - 1] || &#39;&#39;
    )
  };
}


/**
 * 댓글 1건을 사용자가 보는 8열 행으로 변환합니다.
 */
function commentToOutputRow_(state, comment, displayNumber) {
  const snippet = (comment &amp;&amp; comment.snippet) || {};
  const text = String(snippet.textDisplay || &#39;&#39;);

  return [
    String(displayNumber),
    safeCellText_(state.videoChannelTitle || &#39;&#39;),
    safeCellText_(state.videoTitle || &#39;&#39;),
    safeCellText_(snippet.authorDisplayName || &#39;&#39;),
    safeCellText_(text),
    Number(snippet.likeCount || 0),
    apiDateToSheetValue_(snippet.publishedAt),
    calculateCommentBytes_(text)
  ];
}


/**
 * 숨김 내부 시트의 행을 만듭니다.
 */
function buildInternalRow_(
  commentId,
  parentCommentId,
  displayNumber,
  commentType,
  replyStatus,
  nextReplyIndex,
  totalReplyCount,
  replyPageToken
) {
  return [
    safeCellText_(commentId || &#39;&#39;),
    safeCellText_(parentCommentId || &#39;&#39;),
    String(displayNumber || &#39;&#39;),
    String(commentType || &#39;&#39;),
    String(replyStatus || &#39;&#39;),
    nextReplyIndex === &#39;&#39; ? &#39;&#39; : Number(nextReplyIndex || 1),
    totalReplyCount === &#39;&#39; ? &#39;&#39; : Number(totalReplyCount || 0),
    safeCellText_(replyPageToken || &#39;&#39;)
  ];
}


/**
 * 댓글 행과 내부 관리 행을 각각 일괄 기록합니다.
 */
function appendCollectedRecords_(
  commentsSheet,
  internalSheet,
  commentRows,
  internalRows
) {
  if (commentRows.length !== internalRows.length) {
    throwStructuredError_(buildErrorInfo_(
      &#39;INTERNAL_ROW_MISMATCH&#39;,
      &#39;댓글 저장 구조를 확인해야 합니다.&#39;,
      &#39;댓글 행과 내부 관리 행의 개수가 일치하지 않습니다.&#39;,
      [&#39;수집 상태를 기록하는 내부 데이터가 일시적으로 어긋났습니다.&#39;],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;사이드바에서 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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 &lt;= 1) {
    return null;
  }

  const found = internalSheet
    .getRange(2, YT_CFG.INTERNAL_COL.COMMENT_ID, lastRow - 1, 1)
    .createTextFinder(String(commentId || &#39;&#39;))
    .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] || &#39;&#39;),
    displayNumber: String(
      values[YT_CFG.INTERNAL_COL.DISPLAY_NUMBER - 1] || &#39;&#39;
    ),
    replyStatus: String(
      values[YT_CFG.INTERNAL_COL.REPLY_STATUS - 1] || &#39;&#39;
    ),
    nextReplyIndex: Number(
      values[YT_CFG.INTERNAL_COL.NEXT_REPLY_INDEX - 1] || 1
    ),
    replyPageToken: String(
      values[YT_CFG.INTERNAL_COL.REPLY_PAGE_TOKEN - 1] || &#39;&#39;
    )
  };
}


/**
 * 최상위 댓글의 답글 수집 진행 정보를 갱신합니다.
 */
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 || &#39;&#39;
    ]]);
}


/**
 * 완료 처리
 */
function finishCollection_(ss, commentsSheet, state) {
  state.status = &#39;COMPLETED&#39;;
  state.updatedAt = new Date().toISOString();
  state.finishedAt = new Date().toISOString();
  state.message = state.topCount &lt; state.targetTopLevelCount
    ? &#39;댓글 &#39; +
      state.topCount +
      &#39;개와 대댓글 &#39; +
      state.replyCount +
      &#39;개를 수집했습니다.&#39;
    : &#39;댓글 수집을 완료했습니다.&#39;;
  state.error = null;

  finalizeCommentsSheet_(commentsSheet);
  saveLastResult_(state, &#39;COMPLETED&#39;, 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) === &#39;true&#39;;
}


/**
 * 취소 처리
 */
function finalizeCancellation_(ss, commentsSheet, state) {
  const message = &#39;사용자가 수집을 취소했습니다. 이미 저장된 댓글은 유지됩니다.&#39;;

  state.status = &#39;CANCELLED&#39;;
  state.updatedAt = new Date().toISOString();
  state.finishedAt = new Date().toISOString();
  state.message = message;
  state.error = null;

  finalizeCommentsSheet_(commentsSheet);
  saveLastResult_(state, &#39;CANCELLED&#39;, 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_(
    &#39;videos&#39;,
    {
      part: &#39;id&#39;,
      id: &#39;jNQXAC9IVRw&#39;
    },
    apiKey
  );
}


/**
 * 영상 메타데이터를 조회합니다.
 */
function getVideoMetadataWithKey_(videoId, apiKey) {
  const data = youtubeGetWithKey_(
    &#39;videos&#39;,
    {
      part: &#39;snippet,statistics&#39;,
      id: videoId
    },
    apiKey
  );

  if (!Array.isArray(data.items) || data.items.length === 0) {
    throwStructuredError_(buildErrorInfo_(
      &#39;VIDEO_NOT_FOUND&#39;,
      &#39;영상 정보를 찾을 수 없습니다.&#39;,
      &#39;입력한 영상 주소에 해당하는 공개 영상을 YouTube Data API에서 조회하지 못했습니다.&#39;,
      [
        &#39;영상 주소나 11자리 영상 ID가 정확하지 않을 수 있습니다.&#39;,
        &#39;영상이 삭제되었거나 비공개 상태일 수 있습니다.&#39;,
        &#39;연령·지역·계정 권한에 따라 접근이 제한된 영상일 수 있습니다.&#39;
      ],
      [
        &#39;브라우저에서 해당 영상 주소가 정상적으로 열리는지 확인합니다.&#39;,
        &#39;주소 표시줄의 영상 URL 전체를 다시 복사합니다.&#39;,
        &#39;사이드바의 영상 주소를 교체한 뒤 수집을 다시 시작합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: { videoId: videoId }
      }
    ));
  }

  const item = data.items[0];
  const snippet = item.snippet || {};
  const statistics = item.statistics || {};

  return {
    title: snippet.title || &#39;&#39;,
    channelId: snippet.channelId || &#39;&#39;,
    channelTitle: snippet.channelTitle || &#39;&#39;,
    commentCount: statistics.commentCount || &#39;&#39;
  };
}


/**
 * 저장된 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 &amp;&amp; value !== null &amp;&amp; value !== &#39;&#39;;
    })
    .map(function(key) {
      return encodeURIComponent(key) + &#39;=&#39; + encodeURIComponent(queryParams[key]);
    })
    .join(&#39;&amp;&#39;);

  const url = YT_CFG.API_BASE + &#39;/&#39; + resource + &#39;?&#39; + query;
  const transientCodes = [429, 500, 502, 503, 504];

  for (let attempt = 0; attempt &lt; 3; attempt += 1) {
    let response;

    try {
      response = UrlFetchApp.fetch(url, {
        method: &#39;get&#39;,
        muteHttpExceptions: true,
        headers: { Accept: &#39;application/json&#39; }
      });
    } catch (networkError) {
      if (attempt &lt; 2) {
        Utilities.sleep(Math.pow(2, attempt) * 1000);
        continue;
      }

      throwStructuredError_(buildErrorInfo_(
        &#39;NETWORK_REQUEST_FAILED&#39;,
        &#39;YouTube 서버에 연결하지 못했습니다.&#39;,
        &#39;Apps Script에서 YouTube Data API로 보내는 외부 요청이 완료되지 않았습니다.&#39;,
        [
          &#39;Google 서비스 또는 네트워크에 일시적인 장애가 발생했을 수 있습니다.&#39;,
          &#39;Apps Script의 외부 요청 권한이 승인되지 않았을 수 있습니다.&#39;
        ],
        [
          &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
          &#39;Apps Script 권한 승인 화면이 나타나면 요청된 권한을 허용합니다.&#39;,
          &#39;Apps Script의 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;
        ],
        {
          actionLabel: &#39;확인&#39;,
          technical: {
            resource: resource,
            originalMessage: String(networkError &amp;&amp; networkError.message || networkError)
          }
        }
      ));
    }

    const statusCode = response.getResponseCode();
    const responseText = response.getContentText();

    if (statusCode &gt;= 200 &amp;&amp; statusCode &lt; 300) {
      return responseText ? JSON.parse(responseText) : {};
    }

    if (transientCodes.indexOf(statusCode) &gt;= 0 &amp;&amp; attempt &lt; 2) {
      Utilities.sleep(Math.pow(2, attempt) * 1000);
      continue;
    }

    const parsedError = parseYouTubeErrorResponse_(responseText);

    throwStructuredError_(classifyYouTubeApiError_(
      statusCode,
      parsedError.reason,
      parsedError.message,
      resource
    ));
  }

  throwStructuredError_(buildErrorInfo_(
    &#39;YOUTUBE_REQUEST_REPEATED_FAILURE&#39;,
    &#39;YouTube API 요청이 반복적으로 실패했습니다.&#39;,
    &#39;일시적인 서버 장애가 재시도 후에도 계속되었습니다.&#39;,
    [&#39;YouTube 또는 Google 네트워크 서비스가 불안정할 수 있습니다.&#39;],
    [
      &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
      &#39;반복되면 Apps Script의 ‘실행’ 기록을 확인합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: { resource: resource } }
  ));
}


function parseYouTubeErrorResponse_(responseText) {
  let reason = &#39;&#39;;
  let message = String(responseText || &#39;&#39;);

  try {
    const parsed = JSON.parse(responseText);

    if (parsed.error) {
      message = parsed.error.message || message;

      if (Array.isArray(parsed.error.errors) &amp;&amp; parsed.error.errors[0]) {
        reason = parsed.error.errors[0].reason || &#39;&#39;;
      }

      if (!reason &amp;&amp; parsed.error.status) {
        reason = parsed.error.status;
      }
    }
  } catch (parseError) {
    // JSON이 아닌 오류 본문은 문자열 그대로 분류합니다.
  }

  return {
    reason: String(reason || &#39;&#39;),
    message: String(message || &#39;&#39;)
  };
}


/**
 * YouTube API 오류를 한국어 모달용 정보로 분류합니다.
 */
function classifyYouTubeApiError_(statusCode, reason, message, resource) {
  const reasonText = String(reason || &#39;&#39;);
  const messageText = String(message || &#39;&#39;);
  const combined = (reasonText + &#39; &#39; + messageText).toLowerCase();
  const technical = {
    httpStatus: statusCode,
    apiReason: reasonText,
    resource: resource,
    originalMessage: messageText
  };

  if (
    combined.indexOf(&#39;keyinvalid&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api key not valid&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api_key_invalid&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_INVALID&#39;,
      &#39;API 키를 사용할 수 없습니다.&#39;,
      &#39;입력한 API 키가 정확하지 않거나 삭제·재발급되어 현재 요청에 사용할 수 없습니다.&#39;,
      [
        &#39;API 키 일부가 누락되었을 수 있습니다.&#39;,
        &#39;Google Cloud에서 기존 키가 삭제되거나 재발급되었을 수 있습니다.&#39;,
        &#39;API 키가 아닌 다른 프로젝트 값을 입력했을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;키 유형이 ‘API 키’인지 확인합니다.&#39;,
        &#39;API 키 전체를 다시 복사합니다.&#39;,
        &#39;사이드바에서 API 키를 다시 입력하고 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;accessnotconfigured&#39;) &gt;= 0 ||
    combined.indexOf(&#39;access not configured&#39;) &gt;= 0 ||
    combined.indexOf(&#39;service_disabled&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api_disabled&#39;) &gt;= 0 ||
    combined.indexOf(&#39;has not been used in project&#39;) &gt;= 0 ||
    combined.indexOf(&#39;it is disabled&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_API_NOT_ENABLED&#39;,
      &#39;YouTube Data API v3가 활성화되어 있지 않습니다.&#39;,
      &#39;API 키가 속한 Google Cloud 프로젝트에서 YouTube Data API v3 사용 설정을 켜야 합니다.&#39;,
      [
        &#39;API 키를 만든 프로젝트에서 YouTube Data API v3를 아직 활성화하지 않았습니다.&#39;,
        &#39;API를 활성화한 프로젝트와 API 키를 발급한 프로젝트가 다를 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console에서 API 키를 만든 프로젝트를 선택합니다.&#39;,
        &#39;‘API 및 서비스 → 라이브러리’로 이동합니다.&#39;,
        &#39;‘YouTube Data API v3’를 검색하고 ‘사용’을 누릅니다.&#39;,
        &#39;설정 반영을 위해 1~5분 기다린 뒤 API 키를 다시 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;YouTube Data API 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/library/youtube.googleapis.com&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;iprefererblocked&#39;) &gt;= 0 ||
    combined.indexOf(&#39;referer&#39;) &gt;= 0 &amp;&amp; combined.indexOf(&#39;blocked&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api key restrictions&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_APPLICATION_RESTRICTION&#39;,
      &#39;API 키의 애플리케이션 제한이 Apps Script 요청을 차단했습니다.&#39;,
      &#39;현재 키에 설정된 웹사이트·IP 제한이 Apps Script의 UrlFetch 요청 방식과 맞지 않습니다.&#39;,
      [
        &#39;HTTP 리퍼러 제한 또는 특정 IP 주소 제한이 적용되어 있을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;사용 중인 API 키의 애플리케이션 제한을 확인합니다.&#39;,
        &#39;Apps Script에서 사용할 수 있는 제한 방식으로 조정합니다.&#39;,
        &#39;API 제한은 ‘YouTube Data API v3’로 유지합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 제한 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;accessnotconfigured&#39;) &lt; 0 &amp;&amp;
    (
      combined.indexOf(&#39;forbidden&#39;) &gt;= 0 &amp;&amp;
      combined.indexOf(&#39;api&#39;) &gt;= 0 &amp;&amp;
      combined.indexOf(&#39;key&#39;) &gt;= 0
    )
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_API_RESTRICTION&#39;,
      &#39;API 키의 API 제한 설정을 확인해야 합니다.&#39;,
      &#39;현재 API 키가 YouTube Data API v3 호출을 허용하지 않을 수 있습니다.&#39;,
      [
        &#39;API 키의 ‘API 제한’ 목록에서 YouTube Data API v3가 빠져 있을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;사용 중인 API 키를 선택합니다.&#39;,
        &#39;‘API 제한’에서 YouTube Data API v3를 허용합니다.&#39;,
        &#39;저장 후 1~5분 뒤 다시 실행합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 제한 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;quotaexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;dailylimitexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;daily limit&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_QUOTA_EXCEEDED&#39;,
      &#39;YouTube API의 오늘 할당량을 모두 사용했습니다.&#39;,
      &#39;현재 Google Cloud 프로젝트에서 오늘 사용할 수 있는 YouTube Data API 할당량이 남아 있지 않습니다.&#39;,
      [&#39;같은 프로젝트의 다른 프로그램에서도 YouTube API 할당량을 사용했을 수 있습니다.&#39;],
      [
        &#39;Google Cloud Console에서 YouTube Data API 사용량을 확인합니다.&#39;,
        &#39;일일 할당량이 갱신된 뒤 다시 실행합니다.&#39;,
        &#39;필요하면 다음 수집의 댓글 개수를 줄입니다.&#39;
      ],
      {
        helpLabel: &#39;YouTube API 할당량 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas&#39;,
        actionLabel: &#39;확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;ratelimitexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;userratelimitexceeded&#39;) &gt;= 0 ||
    statusCode === 429
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_RATE_LIMIT&#39;,
      &#39;짧은 시간에 API 요청이 많이 발생했습니다.&#39;,
      &#39;YouTube Data API가 일시적으로 요청 속도를 제한했습니다.&#39;,
      [&#39;여러 수집 작업을 연속으로 실행했을 수 있습니다.&#39;],
      [
        &#39;현재 작업이 자동 재개되는지 확인합니다.&#39;,
        &#39;몇 분 뒤 다시 실행합니다.&#39;,
        &#39;동시에 여러 스프레드시트에서 같은 API 키를 사용하고 있다면 실행 횟수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (combined.indexOf(&#39;commentsdisabled&#39;) &gt;= 0) {
    return buildErrorInfo_(
      &#39;COMMENTS_DISABLED&#39;,
      &#39;이 영상에서는 댓글을 수집할 수 없습니다.&#39;,
      &#39;영상 게시자가 댓글 기능을 사용하지 않도록 설정했습니다.&#39;,
      [&#39;영상의 댓글 기능이 꺼져 있거나 YouTube 정책에 따라 제한되었습니다.&#39;],
      [
        &#39;영상 페이지에서 댓글 영역이 표시되는지 확인합니다.&#39;,
        &#39;댓글이 허용된 다른 영상 주소를 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;videonotfound&#39;) &gt;= 0 ||
    combined.indexOf(&#39;video not found&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;VIDEO_NOT_FOUND&#39;,
      &#39;영상 정보를 찾을 수 없습니다.&#39;,
      &#39;영상 주소가 잘못되었거나 영상이 삭제·비공개 상태일 수 있습니다.&#39;,
      [
        &#39;영상 ID가 정확하지 않을 수 있습니다.&#39;,
        &#39;영상 공개 상태가 변경되었을 수 있습니다.&#39;
      ],
      [
        &#39;브라우저에서 영상 주소가 정상적으로 열리는지 확인합니다.&#39;,
        &#39;영상 URL 전체를 다시 복사해 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (statusCode &gt;= 500) {
    return buildErrorInfo_(
      &#39;YOUTUBE_SERVER_ERROR&#39;,
      &#39;YouTube 서버에서 일시적인 오류가 발생했습니다.&#39;,
      &#39;YouTube Data API 서버가 현재 요청을 정상적으로 처리하지 못했습니다.&#39;,
      [&#39;YouTube 측의 일시적인 서비스 장애일 수 있습니다.&#39;],
      [
        &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
        &#39;진행 중인 수집이라면 자동 재개 여부를 확인합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (statusCode === 403) {
    return buildErrorInfo_(
      &#39;YOUTUBE_ACCESS_FORBIDDEN&#39;,
      &#39;YouTube 데이터에 접근할 수 없습니다.&#39;,
      &#39;API 키 설정, 영상 공개 상태 또는 Google Cloud 프로젝트 권한을 확인해야 합니다.&#39;,
      [
        &#39;API 키 제한 설정이 요청을 차단했을 수 있습니다.&#39;,
        &#39;영상이 공개 API에서 접근할 수 없는 상태일 수 있습니다.&#39;
      ],
      [
        &#39;API 키의 API 제한과 애플리케이션 제한을 확인합니다.&#39;,
        &#39;영상이 공개 상태인지 확인합니다.&#39;,
        &#39;Google Cloud에서 YouTube Data API v3가 활성화되어 있는지 확인합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (statusCode === 400) {
    return buildErrorInfo_(
      &#39;YOUTUBE_BAD_REQUEST&#39;,
      &#39;YouTube API 요청 형식을 확인해야 합니다.&#39;,
      &#39;영상 주소 또는 요청 매개변수 중 하나를 YouTube API가 처리하지 못했습니다.&#39;,
      [&#39;영상 주소가 올바르지 않거나 API 요청 값이 허용 범위를 벗어났을 수 있습니다.&#39;],
      [
        &#39;영상 주소를 다시 확인합니다.&#39;,
        &#39;수집 개수와 정렬 기준을 확인합니다.&#39;,
        &#39;같은 문제가 계속되면 새 수집으로 다시 시작합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;입력값 확인&#39;,
        technical: technical
      }
    );
  }

  return buildErrorInfo_(
    &#39;YOUTUBE_API_ERROR&#39;,
    &#39;YouTube API 요청을 완료하지 못했습니다.&#39;,
    &#39;YouTube Data API가 요청을 거부하거나 처리하지 못했습니다.&#39;,
    [&#39;API 키, 영상 공개 상태, 할당량 또는 네트워크 상태를 확인해야 합니다.&#39;],
    [
      &#39;API 키 설정과 YouTube Data API 활성화 여부를 확인합니다.&#39;,
      &#39;영상 주소가 공개 영상인지 확인합니다.&#39;,
      &#39;잠시 뒤 다시 실행합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: technical }
  );
}


/**
 * 영상 URL 또는 11자리 영상 ID에서 videoId를 추출합니다.
 */
function extractVideoId_(input) {
  const text = String(input || &#39;&#39;).trim();

  if (/^[A-Za-z0-9_-]{11}$/.test(text)) {
    return text;
  }

  const patterns = [
    /[?&amp;]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 &lt; patterns.length; i += 1) {
    const match = text.match(patterns[i]);

    if (match) {
      return match[1];
    }
  }

  throwStructuredError_(buildErrorInfo_(
    &#39;INVALID_VIDEO_URL&#39;,
    &#39;YouTube 영상 주소를 확인해야 합니다.&#39;,
    &#39;입력한 값에서 11자리 YouTube 영상 ID를 찾지 못했습니다.&#39;,
    [
      &#39;영상 주소 일부가 빠졌을 수 있습니다.&#39;,
      &#39;채널·재생목록 주소를 입력했을 수 있습니다.&#39;
    ],
    [
      &#39;YouTube에서 수집할 영상을 엽니다.&#39;,
      &#39;브라우저 주소 표시줄의 영상 URL 전체를 복사합니다.&#39;,
      &#39;사이드바의 영상 주소 입력란에 붙여 넣습니다.&#39;
    ],
    {
      focusTarget: &#39;videoUrl&#39;,
      actionLabel: &#39;영상 주소 다시 입력&#39;
    }
  ));
}


/**
 * 사이드바 입력값을 검증합니다.
 */
function normalizeSidebarInput_(formData) {
  const form = formData || {};
  const videoUrl = String(form.videoUrl || &#39;&#39;).trim();
  const limit = parseInt(form.limit, 10);

  if (!videoUrl) {
    throwStructuredError_(buildErrorInfo_(
      &#39;VIDEO_URL_REQUIRED&#39;,
      &#39;YouTube 영상 주소를 입력해야 합니다.&#39;,
      &#39;수집 대상 영상 주소가 비어 있습니다.&#39;,
      [&#39;영상 주소 입력란에 값이 없습니다.&#39;],
      [&#39;수집할 YouTube 영상의 URL을 입력합니다.&#39;],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 입력&#39;
      }
    ));
  }

  if (!Number.isInteger(limit)) {
    throwStructuredError_(buildErrorInfo_(
      &#39;COMMENT_LIMIT_INVALID&#39;,
      &#39;수집 개수를 정수로 입력해야 합니다.&#39;,
      &#39;수집할 최상위 댓글 개수가 올바른 정수 형식이 아닙니다.&#39;,
      [&#39;빈 값, 소수 또는 문자가 입력되었을 수 있습니다.&#39;],
      [&#39;1 이상의 정수를 입력합니다.&#39;],
      {
        focusTarget: &#39;limit&#39;,
        actionLabel: &#39;수집 개수 입력&#39;
      }
    ));
  }

  if (limit &lt; YT_CFG.MIN_LIMIT || limit &gt; YT_CFG.MAX_LIMIT) {
    throwStructuredError_(buildErrorInfo_(
      &#39;COMMENT_LIMIT_OUT_OF_RANGE&#39;,
      &#39;수집 개수가 허용 범위를 벗어났습니다.&#39;,
      &#39;수집 개수는 &#39; + YT_CFG.MIN_LIMIT + &#39;개 이상 &#39; +
        YT_CFG.MAX_LIMIT + &#39;개 이하로 입력해야 합니다.&#39;,
      [&#39;입력한 개수가 코드에 설정된 운영 범위를 벗어났습니다.&#39;],
      [
        YT_CFG.MIN_LIMIT + &#39;~&#39; + YT_CFG.MAX_LIMIT +
          &#39; 사이의 정수를 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;limit&#39;,
        actionLabel: &#39;수집 개수 수정&#39;
      }
    ));
  }

  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 ... 순서로 만듭니다.
 *
 * 새 스프레드시트에 기본 빈 &#39;시트1&#39; 하나만 존재하는 경우에는 그 시트를
 * 첫 결과 시트로 사용합니다. 그 이후 수집부터는 항상 새 시트를 추가합니다.
 */
function createNextOutputSheet_(ss) {
  const sheets = ss.getSheets();

  if (
    sheets.length === 1 &amp;&amp;
    sheets[0].getName() === YT_CFG.OUTPUT_SHEET_PREFIX + &#39;1&#39; &amp;&amp;
    isSheetCompletelyBlank_(sheets[0])
  ) {
    prepareOutputSheet_(sheets[0]);
    return sheets[0];
  }

  let maxNumber = 0;
  const namePattern = new RegExp(
    &#39;^&#39; + escapeRegExp_(YT_CFG.OUTPUT_SHEET_PREFIX) + &#39;(\\d+)$&#39;
  );

  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 &amp;&amp; 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(&#39;A:A&#39;).setNumberFormat(&#39;@&#39;);
  formatCommentsSheet_(sheet);
}


/**
 * 새 수집을 위해 숨김 내부 시트만 초기화합니다.
 * 이전 결과 시트의 내용은 변경하지 않습니다.
 */
function resetInternalData_(ss) {
  const internal = ss.getSheetByName(YT_CFG.INTERNAL_SHEET);

  if (!internal) {
    throwStructuredError_(buildErrorInfo_(
      &#39;INTERNAL_SHEET_NOT_FOUND&#39;,
      &#39;내부 관리 시트를 준비하지 못했습니다.&#39;,
      &#39;댓글 수집 상태를 저장하는 숨김 시트가 존재하지 않습니다.&#39;,
      [&#39;내부 시트 생성 과정이 완료되지 않았습니다.&#39;],
      [&#39;사이드바를 닫았다가 다시 열고 새 수집을 시작합니다.&#39;],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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 &gt; 0) {
    outputSheet = ss.getSheets().find(function(sheet) {
      return sheet.getSheetId() === expectedId;
    }) || null;
  }

  if (!outputSheet &amp;&amp; state.outputSheetName) {
    outputSheet = ss.getSheetByName(String(state.outputSheetName));
  }

  if (!outputSheet) {
    throwStructuredError_(buildErrorInfo_(
      &#39;OUTPUT_SHEET_NOT_FOUND&#39;,
      &#39;수집 결과 시트를 찾을 수 없습니다.&#39;,
      &#39;현재 수집 작업과 연결된 결과 시트가 삭제되었거나 이름과 식별 정보가 변경되었습니다.&#39;,
      [
        &#39;수집 중 결과 시트를 삭제했을 수 있습니다.&#39;,
        &#39;스프레드시트 구조가 외부 작업으로 변경되었을 수 있습니다.&#39;
      ],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;사이드바에서 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;새 수집 필요&#39; }
    ));
  }

  return outputSheet;
}


function escapeRegExp_(text) {
  return String(text).replace(/[.*+?^${}()|[\]\\]/g, &#39;\\$&amp;&#39;);
}


/**
 * 이전 버전이 만든 수집로그 시트를 제거합니다.
 */
function deleteLegacyLogSheet_(ss) {
  const logSheet = ss.getSheetByName(YT_CFG.LEGACY_LOG_SHEET);

  if (logSheet &amp;&amp; ss.getSheets().length &gt; 1) {
    ss.deleteSheet(logSheet);
  }
}


/**
 * 댓글 시트를 번호 순으로 정렬하고 최종 서식을 적용합니다.
 */
function finalizeCommentsSheet_(sheet) {
  sortCommentsByDisplayNumber_(sheet);
  formatCommentsSheet_(sheet);
}


function sortCommentsByDisplayNumber_(sheet) {
  const lastRow = sheet.getLastRow();

  if (lastRow &lt;= 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] || &#39;&#39;).split(&#39;_&#39;);
    return [
      Number(parts[0] || 0),
      parts.length &gt; 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(&#39;bold&#39;)
    .setWrap(true)
    .setVerticalAlignment(&#39;middle&#39;);

  sheet.getRange(&#39;A:A&#39;).setNumberFormat(&#39;@&#39;);

  if (lastRow &gt; 1) {
    sheet
      .getRange(2, 7, lastRow - 1, 1)
      .setNumberFormat(&#39;yyyy-mm-dd hh:mm:ss&#39;);

    sheet
      .getRange(2, 5, lastRow - 1, 1)
      .setWrap(true)
      .setVerticalAlignment(&#39;top&#39;);

    sheet
      .getRange(2, 1, lastRow - 1, YT_CFG.HEADERS.length)
      .setVerticalAlignment(&#39;top&#39;);

    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 &lt;= 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 &lt;= 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] === &#39;topLevel&#39;) {
      counts.top += 1;
    } else if (row[0] === &#39;reply&#39;) {
      counts.reply += 1;
    }
  });

  return counts;
}


/**
 * 댓글 글자수를 지정된 바이트 규칙으로 계산합니다.
 * - ASCII 영역(영문, 숫자, 공백, 일반 문장부호): 1바이트
 * - 한글을 포함한 비ASCII 유니코드 문자: 코드 포인트당 2바이트
 */
function calculateCommentBytes_(value) {
  const text = String(value || &#39;&#39;);
  let bytes = 0;

  Array.from(text).forEach(function(character) {
    const codePoint = character.codePointAt(0);
    bytes += codePoint &lt;= 0x7F ? 1 : 2;
  });

  return bytes;
}


/**
 * 외부 문자열이 시트 수식으로 실행되는 것을 방지합니다.
 */
function safeCellText_(value) {
  const text = value === undefined || value === null ? &#39;&#39; : String(value);
  return /^[=+\-@]/.test(text) ? &quot;&#39;&quot; + text : text;
}


function apiDateToSheetValue_(value) {
  return value ? new Date(value) : &#39;&#39;;
}


function ensureColumnCount_(sheet, requiredColumns) {
  const current = sheet.getMaxColumns();

  if (current &lt; requiredColumns) {
    sheet.insertColumnsAfter(current, requiredColumns - current);
  }
}


function ensureExactColumnCount_(sheet, requiredColumns) {
  ensureColumnCount_(sheet, requiredColumns);

  const current = sheet.getMaxColumns();

  if (current &gt; 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_(
      &#39;API_KEY_REQUIRED&#39;,
      &#39;API 키 설정이 필요합니다.&#39;,
      &#39;댓글 수집을 시작하기 전에 YouTube Data API 키를 저장해야 합니다.&#39;,
      [&#39;현재 사용자 계정에 저장된 API 키가 없습니다.&#39;],
      [
        &#39;사이드바 상단의 API 키 입력란에 키를 입력합니다.&#39;,
        &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
      ],
      {
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 입력&#39;
      }
    ));
  }

  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 &gt; 8500) {
    throwStructuredError_(buildErrorInfo_(
      &#39;STATE_STORAGE_LIMIT&#39;,
      &#39;수집 진행 상태를 저장할 수 없습니다.&#39;,
      &#39;Apps Script 사용자 속성의 안전 저장 크기를 초과했습니다.&#39;,
      [&#39;진행 상태 데이터가 예상보다 커졌습니다.&#39;],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;댓글 수집 개수를 줄여 새로 실행합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, 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 || &#39;&#39;,
    videoId: state.videoId || &#39;&#39;,
    videoTitle: state.videoTitle || &#39;&#39;,
    targetTopLevelCount: Number(state.targetTopLevelCount || 0),
    topCount: Number(state.topCount || 0),
    replyCount: Number(state.replyCount || 0),
    apiCalls: Number(state.apiCalls || 0),
    startedAt: state.startedAt || &#39;&#39;,
    updatedAt: new Date().toISOString(),
    finishedAt: new Date().toISOString(),
    message: message || &#39;&#39;,
    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_(
      &#39;STATE_VERSION_MISMATCH&#39;,
      &#39;이전 코드에서 시작된 수집 작업을 이어갈 수 없습니다.&#39;,
      &#39;현재 Code.gs의 댓글 저장 구조가 이전 실행 상태와 다릅니다.&#39;,
      [&#39;고정 결과 시트 또는 정렬 기준을 사용하던 이전 코드의 작업 상태가 남아 있습니다.&#39;],
      [
        &#39;사이드바에서 진행 중인 수집을 취소합니다.&#39;,
        &#39;현재 코드로 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;새 수집 필요&#39; }
    ));
  }
}


function normalizeState_(state) {
  state.currentReplyParentId = state.currentReplyParentId || &#39;&#39;;
  state.nextReplyPageToken = state.nextReplyPageToken || &#39;&#39;;
  state.nextThreadPageToken = state.nextThreadPageToken || &#39;&#39;;
  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 || &#39;&#39;);
  state.error = state.error || null;
}


function maskApiKey_(apiKey) {
  const key = String(apiKey || &#39;&#39;);
  return &#39;••••••••••••&#39; + 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 &gt; 0
    ? Math.min(100, Math.round((top / target) * 100))
    : 0;

  let stage = &#39;대기 중&#39;;

  if (status === &#39;RUNNING&#39;) {
    if (state.currentReplyParentId) {
      stage = &#39;답글 수집 중&#39;;
    } else if (!state.threadDone) {
      stage = &#39;최상위 댓글 수집 중&#39;;
    } else {
      stage = &#39;마무리 중&#39;;
    }
  } else if (status === &#39;COMPLETED&#39;) {
    stage = &#39;완료&#39;;
  } else if (status === &#39;ERROR&#39;) {
    stage = &#39;오류&#39;;
  } else if (status === &#39;CANCELLED&#39;) {
    stage = &#39;취소됨&#39;;
  }

  return {
    status: status,
    stage: stage,
    outputSheetId: Number(state.outputSheetId || 0),
    outputSheetName: state.outputSheetName || &#39;&#39;,
    videoId: state.videoId || &#39;&#39;,
    videoTitle: state.videoTitle || &#39;&#39;,
    targetTopLevelCount: target,
    topCount: top,
    replyCount: reply,
    totalRows: top + reply,
    apiCalls: Number(state.apiCalls || 0),
    percent: percent,
    startedAt: state.startedAt || &#39;&#39;,
    updatedAt: state.updatedAt || &#39;&#39;,
    message: state.message || &#39;&#39;,
    error: state.error || null
  };
}


function buildServerProgressMessage_(state) {
  return (
    &#39;최상위 댓글 &#39; +
    state.topCount +
    &#39;/&#39; +
    state.targetTopLevelCount +
    &#39;개, 답글 &#39; +
    state.replyCount +
    &#39;개를 저장했습니다.&#39;
  );
}


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 || &#39;UNKNOWN_ERROR&#39;),
    title: String(title || &#39;작업을 완료하지 못했습니다.&#39;),
    summary: String(summary || &#39;처리 중 문제가 발생했습니다.&#39;),
    causes: Array.isArray(causes) ? causes.map(String) : [],
    steps: Array.isArray(steps) ? steps.map(String) : [],
    helpLabel: String(opts.helpLabel || &#39;&#39;),
    helpUrl: String(opts.helpUrl || &#39;&#39;),
    focusTarget: String(opts.focusTarget || &#39;&#39;),
    actionLabel: String(opts.actionLabel || &#39;확인&#39;),
    technical:
      opts.technical &amp;&amp; typeof opts.technical === &#39;object&#39;
        ? 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 || &#39;&#39;);
  const markerIndex = raw.indexOf(YT_CFG.STRUCTURED_ERROR_PREFIX);

  if (markerIndex &lt; 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 &amp;&amp; error.message
    ? String(error.message)
    : String(error || &#39;&#39;);

  const structured = extractStructuredError_(raw);

  if (structured) {
    return structured;
  }

  const lower = raw.toLowerCase();
  const technical = {
    context: String(context || &#39;작업 실행&#39;),
    originalMessage: raw
  };

  if (
    lower.indexOf(&#39;authorization is required&#39;) &gt;= 0 ||
    lower.indexOf(&#39;you do not have permission&#39;) &gt;= 0 ||
    lower.indexOf(&#39;permissions&#39;) &gt;= 0 &amp;&amp; lower.indexOf(&#39;required&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;,
      &#39;Google 권한 승인이 필요합니다.&#39;,
      &#39;현재 계정이 스프레드시트 또는 외부 API 요청 권한을 아직 승인하지 않았습니다.&#39;,
      [&#39;Apps Script를 처음 실행했거나 새로운 권한이 추가되었습니다.&#39;],
      [
        &#39;스프레드시트 메뉴에서 사이드바를 다시 엽니다.&#39;,
        &#39;권한 검토 화면에서 현재 Google 계정을 선택합니다.&#39;,
        &#39;요청되는 권한을 허용한 뒤 다시 실행합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (
    lower.indexOf(&#39;maximum execution time&#39;) &gt;= 0 ||
    lower.indexOf(&#39;exceeded maximum execution time&#39;) &gt;= 0 ||
    lower.indexOf(&#39;timed out&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;,
      &#39;한 번의 실행 가능 시간을 초과했습니다.&#39;,
      &#39;댓글 또는 답글 처리 시간이 길어 현재 실행 구간이 종료되었습니다.&#39;,
      [&#39;대량 댓글 수집으로 한 번의 Apps Script 실행 시간이 길어졌습니다.&#39;],
      [
        &#39;사이드바를 다시 열어 진행 상태를 확인합니다.&#39;,
        &#39;멈춘 경우 ‘중단 지점부터 실행’을 한 번 실행합니다.&#39;,
        &#39;같은 문제가 반복되면 수집 개수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (
    lower.indexOf(&#39;service invoked too many times&#39;) &gt;= 0 ||
    lower.indexOf(&#39;quota&#39;) &gt;= 0 &amp;&amp; lower.indexOf(&#39;apps script&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_SERVICE_QUOTA&#39;,
      &#39;Apps Script 서비스 사용 한도를 초과했습니다.&#39;,
      &#39;현재 Google 계정 또는 스크립트의 일일 서비스 사용량이 허용 범위를 넘었습니다.&#39;,
      [&#39;짧은 시간에 많은 외부 요청이나 스프레드시트 쓰기 작업이 실행되었습니다.&#39;],
      [
        &#39;잠시 뒤 다시 실행합니다.&#39;,
        &#39;같은 날 반복되면 다음 날 다시 실행합니다.&#39;,
        &#39;한 번에 수집하는 댓글 개수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  return buildErrorInfo_(
    &#39;UNEXPECTED_ERROR&#39;,
    &#39;예상하지 못한 오류가 발생했습니다.&#39;,
    &#39;오류 유형을 자동으로 판별하지 못했습니다.&#39;,
    [
      &#39;네트워크, 권한, Google 서비스 상태 또는 실행 환경에서 문제가 발생했을 수 있습니다.&#39;
    ],
    [
      &#39;사이드바를 닫았다가 다시 열고 같은 작업을 한 번 더 실행합니다.&#39;,
      &#39;Apps Script 편집기의 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;,
      &#39;API 키와 영상 주소를 다시 확인합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: technical }
  );
}
</pre></section><section><h3>Sidebar.html</h3><pre>&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;meta charset=&quot;utf-8&quot;&gt;
    &lt;style&gt;
      :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, &#39;Noto Sans KR&#39;, 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=&#39;text&#39;],
      input[type=&#39;password&#39;],
      input[type=&#39;number&#39;] {
        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);
        }
      }
    &lt;/style&gt;
  &lt;/head&gt;

  &lt;body&gt;
    &lt;h1&gt;YouTube 댓글 수집기&lt;/h1&gt;
    &lt;p class=&quot;subtitle&quot;&gt;YouTube Data API v3 → Google Sheets&lt;/p&gt;

    &lt;div id=&quot;message&quot; class=&quot;message&quot;&gt;&lt;/div&gt;

    &lt;section class=&quot;card&quot;&gt;
      &lt;h2&gt;1. API 키&lt;/h2&gt;

      &lt;div id=&quot;apiMissingView&quot;&gt;
        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;apiKey&quot;&gt;YouTube Data API 키&lt;/label&gt;
          &lt;input
            id=&quot;apiKey&quot;
            type=&quot;password&quot;
            autocomplete=&quot;off&quot;
            placeholder=&quot;AIza...&quot;
          &gt;
          &lt;div class=&quot;help&quot;&gt;
            최초 1회 저장합니다. 키는 사용자별 Apps Script 속성에 보관됩니다.
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;button id=&quot;saveApiButton&quot; class=&quot;primary&quot; type=&quot;button&quot;&gt;
          API 키 확인 후 저장
        &lt;/button&gt;
      &lt;/div&gt;

      &lt;div id=&quot;apiSavedView&quot; class=&quot;hidden&quot;&gt;
        &lt;div class=&quot;row between&quot;&gt;
          &lt;div class=&quot;status-line&quot;&gt;
            &lt;span class=&quot;badge success&quot;&gt;설정 완료&lt;/span&gt;
            &lt;span id=&quot;apiKeyMask&quot; class=&quot;masked-key&quot;&gt;&lt;/span&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;div class=&quot;row&quot; style=&quot;margin-top: 10px;&quot;&gt;
          &lt;button id=&quot;changeApiButton&quot; class=&quot;secondary small-button&quot; type=&quot;button&quot;&gt;
            키 변경
          &lt;/button&gt;
          &lt;button id=&quot;deleteApiButton&quot; class=&quot;danger small-button&quot; type=&quot;button&quot;&gt;
            키 삭제
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/section&gt;

    &lt;section class=&quot;card soft&quot;&gt;
      &lt;h2&gt;2. 수집 설정&lt;/h2&gt;

      &lt;div id=&quot;lockedMessage&quot; class=&quot;locked-message&quot;&gt;
        API 키를 저장하면 수집 설정이 활성화됩니다.
      &lt;/div&gt;

      &lt;fieldset id=&quot;collectorFieldset&quot; disabled&gt;
        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;videoUrl&quot;&gt;YouTube 영상 주소&lt;/label&gt;
          &lt;input
            id=&quot;videoUrl&quot;
            type=&quot;text&quot;
            placeholder=&quot;https://www.youtube.com/watch?v=...&quot;
          &gt;
        &lt;/div&gt;

        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;limit&quot;&gt;수집할 최상위 댓글 수&lt;/label&gt;
          &lt;input
            id=&quot;limit&quot;
            type=&quot;number&quot;
            min=&quot;1&quot;
            max=&quot;50000&quot;
            step=&quot;1&quot;
            value=&quot;100&quot;
          &gt;
          &lt;div class=&quot;help&quot;&gt;
            1~50,000개. 답글 포함 시 실제 저장 행 수가 이 값보다 많아질 수 있습니다.
          &lt;/div&gt;
        &lt;/div&gt;


        &lt;div class=&quot;field check-row&quot;&gt;
          &lt;input id=&quot;includeReplies&quot; type=&quot;checkbox&quot; checked&gt;
          &lt;label for=&quot;includeReplies&quot;&gt;
            대댓글도 모두 수집
          &lt;/label&gt;
        &lt;/div&gt;

        &lt;div class=&quot;help&quot; style=&quot;margin-bottom: 10px;&quot;&gt;
          새 수집을 시작할 때마다 ‘시트1’, ‘시트2’, ‘시트3’ 형식의 새 시트가 생성됩니다. 기존 수집 결과는 유지됩니다.
        &lt;/div&gt;

        &lt;button id=&quot;startButton&quot; class=&quot;primary&quot; type=&quot;button&quot;&gt;
          수집 시작
        &lt;/button&gt;
      &lt;/fieldset&gt;
    &lt;/section&gt;

    &lt;section id=&quot;progressCard&quot; class=&quot;card hidden&quot;&gt;
      &lt;div class=&quot;row between&quot;&gt;
        &lt;h2 style=&quot;margin: 0;&quot;&gt;진행 상태&lt;/h2&gt;
        &lt;span id=&quot;progressBadge&quot; class=&quot;badge warning&quot;&gt;대기 중&lt;/span&gt;
      &lt;/div&gt;

      &lt;div id=&quot;progressTitle&quot; class=&quot;progress-title&quot; style=&quot;margin-top: 10px;&quot;&gt;&lt;/div&gt;

      &lt;div class=&quot;progress-bar&quot;&gt;
        &lt;div id=&quot;progressFill&quot; class=&quot;progress-fill&quot;&gt;&lt;/div&gt;
      &lt;/div&gt;

      &lt;div class=&quot;metric-grid&quot;&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;댓글&lt;/div&gt;
          &lt;div id=&quot;topMetric&quot; class=&quot;metric-value&quot;&gt;0 / 0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;대댓글&lt;/div&gt;
          &lt;div id=&quot;replyMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;전체 저장 행&lt;/div&gt;
          &lt;div id=&quot;totalMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;API 호출 횟수&lt;/div&gt;
          &lt;div id=&quot;apiCallMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;p id=&quot;progressMessage&quot; class=&quot;help&quot;&gt;&lt;/p&gt;

      &lt;button id=&quot;cancelButton&quot; class=&quot;danger hidden&quot; type=&quot;button&quot; style=&quot;width: 100%; margin-top: 12px;&quot;&gt;
        진행 중인 수집 취소
      &lt;/button&gt;
    &lt;/section&gt;


    &lt;div
      id=&quot;errorModalBackdrop&quot;
      class=&quot;modal-backdrop hidden&quot;
      role=&quot;dialog&quot;
      aria-modal=&quot;true&quot;
      aria-labelledby=&quot;errorModalTitle&quot;
    &gt;
      &lt;div class=&quot;modal-panel&quot;&gt;
        &lt;div class=&quot;modal-header&quot;&gt;
          &lt;div class=&quot;modal-icon&quot; aria-hidden=&quot;true&quot;&gt;!&lt;/div&gt;

          &lt;div class=&quot;modal-heading&quot;&gt;
            &lt;h2 id=&quot;errorModalTitle&quot; class=&quot;modal-title&quot;&gt;
              문제를 확인해야 합니다.
            &lt;/h2&gt;
            &lt;div id=&quot;errorModalCode&quot; class=&quot;modal-code&quot;&gt;&lt;/div&gt;
          &lt;/div&gt;

          &lt;button
            id=&quot;errorModalCloseButton&quot;
            class=&quot;modal-close&quot;
            type=&quot;button&quot;
            aria-label=&quot;오류 안내 닫기&quot;
          &gt;
            ×
          &lt;/button&gt;
        &lt;/div&gt;

        &lt;div class=&quot;modal-body&quot;&gt;
          &lt;div id=&quot;errorModalSummary&quot; class=&quot;modal-summary&quot;&gt;&lt;/div&gt;

          &lt;section id=&quot;errorCauseSection&quot; class=&quot;modal-section&quot;&gt;
            &lt;h3&gt;발생 가능한 원인&lt;/h3&gt;
            &lt;ul id=&quot;errorCauseList&quot;&gt;&lt;/ul&gt;
          &lt;/section&gt;

          &lt;section id=&quot;errorStepSection&quot; class=&quot;modal-section&quot;&gt;
            &lt;h3&gt;해결 방법&lt;/h3&gt;
            &lt;ol id=&quot;errorStepList&quot;&gt;&lt;/ol&gt;
          &lt;/section&gt;

          &lt;details id=&quot;errorTechnicalDetails&quot; class=&quot;technical-details&quot;&gt;
            &lt;summary&gt;기술 정보 보기&lt;/summary&gt;
            &lt;div id=&quot;errorTechnicalGrid&quot; class=&quot;technical-grid&quot;&gt;&lt;/div&gt;
          &lt;/details&gt;
        &lt;/div&gt;

        &lt;div class=&quot;modal-actions&quot;&gt;
          &lt;a
            id=&quot;errorHelpLink&quot;
            class=&quot;modal-help-link hidden&quot;
            href=&quot;#&quot;
            target=&quot;_blank&quot;
            rel=&quot;noopener noreferrer&quot;
          &gt;&lt;/a&gt;

          &lt;button
            id=&quot;errorModalPrimaryButton&quot;
            class=&quot;primary&quot;
            type=&quot;button&quot;
          &gt;
            확인
          &lt;/button&gt;

          &lt;button
            id=&quot;errorModalDismissButton&quot;
            class=&quot;secondary&quot;
            type=&quot;button&quot;
          &gt;
            닫기
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;script&gt;
      const ERROR_PREFIX = &#39;YT_STRUCTURED_ERROR::&#39;;

      let currentState = null;
      let pollTimer = null;
      let pollInFlight = false;
      let lastShownErrorKey = &#39;&#39;;
      let pendingFocusTarget = &#39;&#39;;

      document.addEventListener(&#39;DOMContentLoaded&#39;, function() {
        bindEvents();
        refreshState(false);
      });

      function bindEvents() {
        document.getElementById(&#39;saveApiButton&#39;).addEventListener(&#39;click&#39;, saveApiKey);
        document.getElementById(&#39;changeApiButton&#39;).addEventListener(&#39;click&#39;, showApiKeyEditor);
        document.getElementById(&#39;deleteApiButton&#39;).addEventListener(&#39;click&#39;, deleteApiKey);
        document.getElementById(&#39;startButton&#39;).addEventListener(&#39;click&#39;, startCollection);
        document.getElementById(&#39;cancelButton&#39;).addEventListener(&#39;click&#39;, cancelCollection);

        document
          .getElementById(&#39;errorModalCloseButton&#39;)
          .addEventListener(&#39;click&#39;, closeErrorModal);

        document
          .getElementById(&#39;errorModalDismissButton&#39;)
          .addEventListener(&#39;click&#39;, closeErrorModal);

        document
          .getElementById(&#39;errorModalPrimaryButton&#39;)
          .addEventListener(&#39;click&#39;, handleErrorModalPrimaryAction);

        document
          .getElementById(&#39;errorModalBackdrop&#39;)
          .addEventListener(&#39;click&#39;, function(event) {
            if (event.target === event.currentTarget) {
              closeErrorModal();
            }
          });

        document.addEventListener(&#39;keydown&#39;, function(event) {
          if (event.key === &#39;Escape&#39;) {
            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, &#39;상태 확인&#39;);
            }
          })
          .getSidebarState();
      }

      function renderState(state) {
        currentState = state || {};

        const configured = Boolean(currentState.apiKeyConfigured);
        const running = Boolean(currentState.running);

        document.getElementById(&#39;apiMissingView&#39;).classList.toggle(&#39;hidden&#39;, configured);
        document.getElementById(&#39;apiSavedView&#39;).classList.toggle(&#39;hidden&#39;, !configured);
        document.getElementById(&#39;apiKeyMask&#39;).textContent = currentState.apiKeyMask || &#39;&#39;;

        document.getElementById(&#39;collectorFieldset&#39;).disabled = !configured || running;
        document.getElementById(&#39;lockedMessage&#39;).classList.toggle(&#39;hidden&#39;, configured);

        document.getElementById(&#39;changeApiButton&#39;).disabled = running;
        document.getElementById(&#39;deleteApiButton&#39;).disabled = running;

        renderProgress(currentState.progress, running);

        if (running) {
          startPolling();
        } else {
          stopPolling();
        }
      }

      function renderProgress(progress, running) {
        const card = document.getElementById(&#39;progressCard&#39;);

        if (!progress) {
          card.classList.add(&#39;hidden&#39;);
          return;
        }

        card.classList.remove(&#39;hidden&#39;);

        const status = progress.status || &#39;&#39;;
        const badge = document.getElementById(&#39;progressBadge&#39;);
        badge.textContent = progress.stage || status;
        badge.className = &#39;badge &#39; + getBadgeClass(status);

        const progressTitle =
          progress.videoTitle || progress.videoId || &#39;YouTube 영상&#39;;
        const outputSheetName = progress.outputSheetName || &#39;&#39;;

        document.getElementById(&#39;progressTitle&#39;).textContent =
          outputSheetName
            ? progressTitle + &#39; · &#39; + outputSheetName
            : progressTitle;

        document.getElementById(&#39;progressFill&#39;).style.width =
          String(progress.percent || 0) + &#39;%&#39;;

        document.getElementById(&#39;topMetric&#39;).textContent =
          formatNumber(progress.topCount) + &#39; / &#39; + formatNumber(progress.targetTopLevelCount);

        document.getElementById(&#39;replyMetric&#39;).textContent = formatNumber(progress.replyCount);
        document.getElementById(&#39;totalMetric&#39;).textContent = formatNumber(progress.totalRows);
        document.getElementById(&#39;apiCallMetric&#39;).textContent = formatNumber(progress.apiCalls);
        document.getElementById(&#39;progressMessage&#39;).textContent = progress.message || &#39;&#39;;
        document.getElementById(&#39;cancelButton&#39;).classList.toggle(&#39;hidden&#39;, !running);

        if (status === &#39;ERROR&#39;) {
          const errorInfo = progress.error || buildFallbackErrorInfo(
            progress.message || &#39;&#39;,
            &#39;댓글 수집&#39;
          );

          const errorKey = [
            errorInfo.code || &#39;UNKNOWN_ERROR&#39;,
            progress.updatedAt || &#39;&#39;,
            progress.videoId || &#39;&#39;
          ].join(&#39;|&#39;);

          if (errorKey !== lastShownErrorKey) {
            lastShownErrorKey = errorKey;
            showErrorModal(errorInfo);
          }
        }
      }

      function saveApiKey() {
        const input = document.getElementById(&#39;apiKey&#39;);
        const button = document.getElementById(&#39;saveApiButton&#39;);
        const apiKey = input.value.trim();

        if (!apiKey) {
          showErrorModal({
            code: &#39;API_KEY_REQUIRED&#39;,
            title: &#39;API 키를 입력해야 합니다.&#39;,
            summary: &#39;API 키 입력란이 비어 있어 검증을 시작할 수 없습니다.&#39;,
            causes: [
              &#39;입력란에 값이 입력되지 않았습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’에서 API 키를 확인합니다.&#39;,
              &#39;키 값을 복사하여 입력란에 붙여 넣습니다.&#39;,
              &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
            ],
            helpLabel: &#39;API 키 관리 화면 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;API 키 입력&#39;
          });
          return;
        }

        setButtonBusy(button, true, &#39;확인 중&#39;);
        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            input.value = &#39;&#39;;
            setButtonBusy(button, false, &#39;API 키 확인 후 저장&#39;);
            renderState(state);
            showMessage(&#39;API 키를 검증하여 저장했습니다.&#39;, &#39;success&#39;);
          })
          .withFailureHandler(function(error) {
            setButtonBusy(button, false, &#39;API 키 확인 후 저장&#39;);
            handleServerError(error, &#39;API 키 검증&#39;);
          })
          .saveApiKey(apiKey);
      }

      function showApiKeyEditor() {
        document.getElementById(&#39;apiSavedView&#39;).classList.add(&#39;hidden&#39;);
        document.getElementById(&#39;apiMissingView&#39;).classList.remove(&#39;hidden&#39;);
        document.getElementById(&#39;apiKey&#39;).focus();
        showMessage(&#39;새 API 키를 입력하면 기존 키가 교체됩니다.&#39;, &#39;info&#39;);
      }

      function deleteApiKey() {
        if (!window.confirm(&#39;저장된 API 키를 삭제하시겠습니까?&#39;)) {
          return;
        }

        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            renderState(state);
            showMessage(&#39;저장된 API 키를 삭제했습니다.&#39;, &#39;success&#39;);
          })
          .withFailureHandler(function(error) {
            handleServerError(error, &#39;API 키 삭제&#39;);
          })
          .deleteApiKey();
      }

      function startCollection() {
        const videoUrl = document.getElementById(&#39;videoUrl&#39;).value.trim();
        const limitText = document.getElementById(&#39;limit&#39;).value;
        const limit = Number(limitText);
        const startButton = document.getElementById(&#39;startButton&#39;);

        if (!videoUrl) {
          showErrorModal({
            code: &#39;VIDEO_URL_REQUIRED&#39;,
            title: &#39;영상 주소를 입력해야 합니다.&#39;,
            summary: &#39;수집 대상 YouTube 영상 주소가 비어 있습니다.&#39;,
            causes: [
              &#39;영상 주소 입력란에 값이 없습니다.&#39;
            ],
            steps: [
              &#39;YouTube에서 수집할 영상을 엽니다.&#39;,
              &#39;주소창의 전체 영상 주소를 복사하여 입력합니다.&#39;
            ],
            focusTarget: &#39;videoUrl&#39;,
            actionLabel: &#39;영상 주소 입력&#39;
          });
          return;
        }

        if (!Number.isInteger(limit) || limit &lt; 1 || limit &gt; 50000) {
          showErrorModal({
            code: &#39;COMMENT_LIMIT_INVALID&#39;,
            title: &#39;수집 개수를 확인해야 합니다.&#39;,
            summary: &#39;수집 개수는 1~50,000 사이의 정수로 입력해야 합니다.&#39;,
            causes: [
              &#39;빈 값, 소수, 문자 또는 허용 범위를 벗어난 수가 입력되었습니다.&#39;
            ],
            steps: [
              &#39;수집 개수 입력란에 1~50,000 사이의 정수를 입력합니다.&#39;,
              &#39;예: 100, 500, 1000&#39;
            ],
            focusTarget: &#39;limit&#39;,
            actionLabel: &#39;수집 개수 수정&#39;
          });
          return;
        }

        const payload = {
          videoUrl: videoUrl,
          limit: limit,
          includeReplies: document.getElementById(&#39;includeReplies&#39;).checked
        };

        setButtonBusy(startButton, true, &#39;준비 중&#39;);
        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            setButtonBusy(startButton, false, &#39;수집 시작&#39;);
            renderState(state);
            const sheetName =
              state &amp;&amp;
              state.progress &amp;&amp;
              state.progress.outputSheetName
                ? state.progress.outputSheetName
                : &#39;&#39;;

            showMessage(
              sheetName
                ? sheetName + &#39;에서 수집 작업을 시작했습니다.&#39;
                : &#39;수집 작업을 시작했습니다.&#39;,
              &#39;info&#39;
            );
            startPolling();

            // 서버에서 첫 수집 구간을 즉시 실행합니다.
            google.script.run
              .withSuccessHandler(function(updatedState) {
                renderState(updatedState);
              })
              .withFailureHandler(function(error) {
                refreshState(true);
                handleServerError(error, &#39;댓글 수집&#39;);
              })
              .runCollectionNow();
          })
          .withFailureHandler(function(error) {
            setButtonBusy(startButton, false, &#39;수집 시작&#39;);
            handleServerError(error, &#39;수집 준비&#39;);
          })
          .startCollectionFromSidebar(payload);
      }

      function cancelCollection() {
        if (!window.confirm(&#39;현재 수집 작업을 취소하시겠습니까? 이미 저장된 행은 유지됩니다.&#39;)) {
          return;
        }

        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            renderState(state);

            if (state &amp;&amp; state.running) {
              showMessage(&#39;취소 요청을 보냈습니다. 현재 API 페이지 처리가 끝나면 안전하게 중단됩니다.&#39;, &#39;info&#39;);
              startPolling();
            } else {
              showMessage(&#39;수집 작업을 취소했습니다.&#39;, &#39;success&#39;);
            }
          })
          .withFailureHandler(function(error) {
            handleServerError(error, &#39;수집 취소&#39;);
          })
          .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 = &#39;&lt;span class=&quot;row&quot; style=&quot;justify-content:center;&quot;&gt;&lt;span class=&quot;spinner&quot;&gt;&lt;/span&gt;&lt;span&gt;&#39; +
            escapeHtml(label) +
            &#39;&lt;/span&gt;&lt;/span&gt;&#39;;
        } else {
          button.textContent = label;
        }
      }

      function showMessage(text, type) {
        const box = document.getElementById(&#39;message&#39;);
        box.textContent = text || &#39;&#39;;
        box.className = &#39;message show &#39; + (type || &#39;info&#39;);
      }

      function clearMessage() {
        const box = document.getElementById(&#39;message&#39;);
        box.textContent = &#39;&#39;;
        box.className = &#39;message&#39;;
      }

      function getBadgeClass(status) {
        if (status === &#39;COMPLETED&#39;) {
          return &#39;success&#39;;
        }

        if (status === &#39;ERROR&#39; || status === &#39;CANCELLED&#39;) {
          return &#39;danger&#39;;
        }

        return &#39;warning&#39;;
      }

      function handleServerError(error, context) {
        const errorInfo = parseServerError(error, context);
        showErrorModal(errorInfo);
      }

      function parseServerError(error, context) {
        if (error &amp;&amp; error.title &amp;&amp; error.summary) {
          return normalizeClientErrorInfo(error);
        }

        const raw = error &amp;&amp; error.message
          ? String(error.message)
          : String(error || &#39;&#39;);

        const structured = extractStructuredError(raw);

        if (structured) {
          return normalizeClientErrorInfo(structured);
        }

        return buildFallbackErrorInfo(raw, context);
      }

      function extractStructuredError(rawText) {
        const raw = String(rawText || &#39;&#39;);
        const markerIndex = raw.indexOf(ERROR_PREFIX);

        if (markerIndex &lt; 0) {
          return null;
        }

        const objectStart = raw.indexOf(
          &#39;{&#39;,
          markerIndex + ERROR_PREFIX.length
        );

        if (objectStart &lt; 0) {
          return null;
        }

        let depth = 0;
        let inString = false;
        let escaped = false;

        for (let index = objectStart; index &lt; raw.length; index += 1) {
          const character = raw.charAt(index);

          if (inString) {
            if (escaped) {
              escaped = false;
            } else if (character === &#39;\\&#39;) {
              escaped = true;
            } else if (character === &#39;&quot;&#39;) {
              inString = false;
            }

            continue;
          }

          if (character === &#39;&quot;&#39;) {
            inString = true;
            continue;
          }

          if (character === &#39;{&#39;) {
            depth += 1;
          } else if (character === &#39;}&#39;) {
            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 || &#39;UNKNOWN_ERROR&#39;),
          title: String(info.title || &#39;작업을 완료하지 못했습니다.&#39;),
          summary: String(
            info.summary ||
            &#39;처리 중 예상하지 못한 문제가 발생했습니다.&#39;
          ),
          causes: Array.isArray(info.causes)
            ? info.causes.map(String)
            : [],
          steps: Array.isArray(info.steps)
            ? info.steps.map(String)
            : [],
          helpLabel: String(info.helpLabel || &#39;&#39;),
          helpUrl: String(info.helpUrl || &#39;&#39;),
          focusTarget: String(info.focusTarget || &#39;&#39;),
          actionLabel: String(info.actionLabel || &#39;확인&#39;),
          technical:
            info.technical &amp;&amp; typeof info.technical === &#39;object&#39;
              ? info.technical
              : {}
        };
      }

      function buildFallbackErrorInfo(rawText, context) {
        const raw = String(rawText || &#39;&#39;);
        const lower = raw.toLowerCase();
        const technical = {
          errorCode: &#39;UNCLASSIFIED_CLIENT_ERROR&#39;,
          context: String(context || &#39;작업 실행&#39;)
        };

        if (
          lower.indexOf(&#39;api key not valid&#39;) &gt;= 0 ||
          lower.indexOf(&#39;keyinvalid&#39;) &gt;= 0 ||
          lower.indexOf(&#39;api_key_invalid&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;API_KEY_INVALID&#39;,
            title: &#39;API 키를 사용할 수 없습니다.&#39;,
            summary: &#39;입력한 API 키가 정확하지 않거나 현재 사용할 수 없는 상태입니다.&#39;,
            causes: [
              &#39;키 일부가 누락되었거나 다른 값을 붙여 넣었을 수 있습니다.&#39;,
              &#39;Google Cloud에서 기존 키를 삭제하거나 재발급했을 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
              &#39;실제 API 키 값을 전체 복사합니다.&#39;,
              &#39;사이드바에서 키를 다시 입력하고 저장합니다.&#39;
            ],
            helpLabel: &#39;API 키 관리 화면 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;API 키 다시 입력&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;API_KEY_INVALID&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;access not configured&#39;) &gt;= 0 ||
          lower.indexOf(&#39;accessnotconfigured&#39;) &gt;= 0 ||
          lower.indexOf(&#39;has not been used in project&#39;) &gt;= 0 ||
          lower.indexOf(&#39;api_disabled&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;YOUTUBE_API_NOT_ENABLED&#39;,
            title: &#39;YouTube Data API v3가 활성화되어 있지 않습니다.&#39;,
            summary: &#39;API 키가 속한 Google Cloud 프로젝트에서 YouTube Data API v3 사용 설정을 켜야 합니다.&#39;,
            causes: [
              &#39;API 키를 만든 프로젝트에서 YouTube Data API v3를 아직 활성화하지 않았습니다.&#39;,
              &#39;API를 활성화한 프로젝트와 API 키를 발급한 프로젝트가 다를 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console에서 API 키를 만든 프로젝트를 선택합니다.&#39;,
              &#39;‘API 및 서비스 → 라이브러리’로 이동합니다.&#39;,
              &#39;‘YouTube Data API v3’를 검색하고 ‘사용’을 누릅니다.&#39;,
              &#39;설정 후 1~5분 뒤 API 키를 다시 저장합니다.&#39;
            ],
            helpLabel: &#39;YouTube Data API 설정 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/library/youtube.googleapis.com&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;설정 확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;YOUTUBE_API_NOT_ENABLED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;commentsdisabled&#39;) &gt;= 0 ||
          lower.indexOf(&#39;comments disabled&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;COMMENTS_DISABLED&#39;,
            title: &#39;이 영상에서는 댓글을 수집할 수 없습니다.&#39;,
            summary: &#39;영상 게시자가 댓글 기능을 사용하지 않도록 설정했습니다.&#39;,
            causes: [
              &#39;영상의 댓글 기능이 꺼져 있거나 YouTube 정책에 따라 제한되었습니다.&#39;
            ],
            steps: [
              &#39;영상 페이지에서 댓글 영역이 표시되는지 확인합니다.&#39;,
              &#39;댓글이 허용된 다른 영상 주소를 입력합니다.&#39;
            ],
            focusTarget: &#39;videoUrl&#39;,
            actionLabel: &#39;영상 주소 다시 입력&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;COMMENTS_DISABLED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;quotaexceeded&#39;) &gt;= 0 ||
          lower.indexOf(&#39;quota_exceeded&#39;) &gt;= 0 ||
          lower.indexOf(&#39;daily limit&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;YOUTUBE_QUOTA_EXCEEDED&#39;,
            title: &#39;YouTube API의 오늘 할당량을 모두 사용했습니다.&#39;,
            summary: &#39;현재 프로젝트에서 오늘 사용할 수 있는 YouTube Data API 할당량이 남아 있지 않습니다.&#39;,
            causes: [
              &#39;같은 프로젝트의 다른 작업에서도 YouTube API를 사용했을 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console에서 YouTube Data API의 오늘 사용량을 확인합니다.&#39;,
              &#39;일일 할당량이 갱신된 뒤 다시 실행합니다.&#39;,
              &#39;다음 수집에서는 요청 댓글 수를 줄입니다.&#39;
            ],
            helpLabel: &#39;YouTube API 할당량 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas&#39;,
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;YOUTUBE_QUOTA_EXCEEDED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;authorization is required&#39;) &gt;= 0 ||
          lower.indexOf(&#39;you do not have permission&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;,
            title: &#39;Google 권한 승인이 필요합니다.&#39;,
            summary: &#39;현재 계정이 스프레드시트 또는 외부 API 요청 권한을 아직 승인하지 않았습니다.&#39;,
            causes: [
              &#39;Apps Script를 처음 실행했거나 새로운 권한이 추가되었습니다.&#39;
            ],
            steps: [
              &#39;스프레드시트 메뉴에서 사이드바를 다시 엽니다.&#39;,
              &#39;권한 검토 화면에서 현재 계정을 선택합니다.&#39;,
              &#39;요청되는 권한을 허용한 뒤 다시 실행합니다.&#39;
            ],
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;timed out&#39;) &gt;= 0 ||
          lower.indexOf(&#39;maximum execution time&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;,
            title: &#39;한 번의 실행 가능 시간을 초과했습니다.&#39;,
            summary: &#39;댓글이 많아 현재 실행 구간이 종료되었습니다.&#39;,
            causes: [
              &#39;대량 댓글 또는 답글 처리 시간이 길어졌습니다.&#39;
            ],
            steps: [
              &#39;진행 상태가 자동으로 이어지는지 확인합니다.&#39;,
              &#39;멈춘 경우 ‘중단 지점부터 실행’을 한 번 실행합니다.&#39;,
              &#39;반복되면 수집 개수를 줄입니다.&#39;
            ],
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;
            })
          });
        }

        return normalizeClientErrorInfo({
          code: &#39;UNEXPECTED_ERROR&#39;,
          title: &#39;예상하지 못한 오류가 발생했습니다.&#39;,
          summary: &#39;오류 유형을 자동으로 판별하지 못했습니다. 입력값과 Apps Script 실행 기록을 확인해야 합니다.&#39;,
          causes: [
            &#39;네트워크 연결, Google 서비스 상태, 권한 또는 실행 환경에서 문제가 발생했을 수 있습니다.&#39;
          ],
          steps: [
            &#39;사이드바를 닫았다가 다시 열고 같은 작업을 한 번 더 실행합니다.&#39;,
            &#39;Apps Script 편집기의 왼쪽 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;,
            &#39;API 키 저장 상태와 영상 주소를 다시 확인합니다.&#39;
          ],
          actionLabel: &#39;확인&#39;,
          technical: technical
        });
      }

      function showErrorModal(errorInfo) {
        const info = normalizeClientErrorInfo(errorInfo);
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);
        const title = document.getElementById(&#39;errorModalTitle&#39;);
        const code = document.getElementById(&#39;errorModalCode&#39;);
        const summary = document.getElementById(&#39;errorModalSummary&#39;);
        const causeSection = document.getElementById(&#39;errorCauseSection&#39;);
        const stepSection = document.getElementById(&#39;errorStepSection&#39;);
        const causeList = document.getElementById(&#39;errorCauseList&#39;);
        const stepList = document.getElementById(&#39;errorStepList&#39;);
        const helpLink = document.getElementById(&#39;errorHelpLink&#39;);
        const primaryButton = document.getElementById(&#39;errorModalPrimaryButton&#39;);

        clearMessage();

        title.textContent = info.title;
        code.textContent = &#39;오류 코드: &#39; + info.code;
        summary.textContent = info.summary;

        renderTextList(causeList, info.causes);
        renderTextList(stepList, info.steps);

        causeSection.classList.toggle(&#39;hidden&#39;, info.causes.length === 0);
        stepSection.classList.toggle(&#39;hidden&#39;, info.steps.length === 0);

        if (
          info.helpUrl &amp;&amp;
          /^https:\/\/console\.cloud\.google\.com\//.test(info.helpUrl)
        ) {
          helpLink.href = info.helpUrl;
          helpLink.textContent = info.helpLabel || &#39;Google Cloud 설정 열기&#39;;
          helpLink.classList.remove(&#39;hidden&#39;);
        } else {
          helpLink.removeAttribute(&#39;href&#39;);
          helpLink.textContent = &#39;&#39;;
          helpLink.classList.add(&#39;hidden&#39;);
        }

        primaryButton.textContent = info.actionLabel || &#39;확인&#39;;
        pendingFocusTarget = info.focusTarget || &#39;&#39;;

        renderTechnicalInfo(info.technical);

        backdrop.classList.remove(&#39;hidden&#39;);
        document.body.style.overflow = &#39;hidden&#39;;

        window.setTimeout(function() {
          primaryButton.focus();
        }, 0);
      }

      function renderTextList(listElement, items) {
        listElement.innerHTML = &#39;&#39;;

        (items || []).forEach(function(item) {
          const listItem = document.createElement(&#39;li&#39;);
          listItem.textContent = String(item);
          listElement.appendChild(listItem);
        });
      }

      function renderTechnicalInfo(technical) {
        const details = document.getElementById(&#39;errorTechnicalDetails&#39;);
        const grid = document.getElementById(&#39;errorTechnicalGrid&#39;);
        const data = technical &amp;&amp; typeof technical === &#39;object&#39;
          ? technical
          : {};

        const labels = {
          errorCode: &#39;분류 코드&#39;,
          httpStatus: &#39;HTTP 상태&#39;,
          reason: &#39;API 사유 코드&#39;,
          resource: &#39;요청 자원&#39;,
          videoId: &#39;영상 ID&#39;,
          context: &#39;실행 단계&#39;
        };

        grid.innerHTML = &#39;&#39;;

        Object.keys(labels).forEach(function(key) {
          if (
            data[key] === undefined ||
            data[key] === null ||
            data[key] === &#39;&#39;
          ) {
            return;
          }

          const label = document.createElement(&#39;div&#39;);
          label.className = &#39;technical-label&#39;;
          label.textContent = labels[key];

          const value = document.createElement(&#39;div&#39;);
          value.className = &#39;technical-value&#39;;
          value.textContent = String(data[key]);

          grid.appendChild(label);
          grid.appendChild(value);
        });

        details.classList.toggle(&#39;hidden&#39;, grid.children.length === 0);
        details.open = false;
      }

      function closeErrorModal() {
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);

        if (backdrop.classList.contains(&#39;hidden&#39;)) {
          return;
        }

        backdrop.classList.add(&#39;hidden&#39;);
        document.body.style.overflow = &#39;&#39;;
        pendingFocusTarget = &#39;&#39;;
      }

      function handleErrorModalPrimaryAction() {
        const focusTarget = pendingFocusTarget;
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);

        backdrop.classList.add(&#39;hidden&#39;);
        document.body.style.overflow = &#39;&#39;;
        pendingFocusTarget = &#39;&#39;;

        focusErrorTarget(focusTarget);
      }

      function focusErrorTarget(targetId) {
        if (!targetId) {
          return;
        }

        if (targetId === &#39;apiKey&#39;) {
          document.getElementById(&#39;apiSavedView&#39;).classList.add(&#39;hidden&#39;);
          document.getElementById(&#39;apiMissingView&#39;).classList.remove(&#39;hidden&#39;);
        }

        const target = document.getElementById(targetId);

        if (target) {
          window.setTimeout(function() {
            target.focus();

            if (typeof target.select === &#39;function&#39;) {
              target.select();
            }
          }, 0);
        }
      }

      function formatNumber(value) {
        return Number(value || 0).toLocaleString(&#39;ko-KR&#39;);
      }

      function escapeHtml(value) {
        return String(value || &#39;&#39;)
          .replace(/&amp;/g, &#39;&amp;amp;&#39;)
          .replace(/&lt;/g, &#39;&amp;lt;&#39;)
          .replace(/&gt;/g, &#39;&amp;gt;&#39;)
          .replace(/&quot;/g, &#39;&amp;quot;&#39;)
          .replace(/&#39;/g, &#39;&amp;#039;&#39;);
      }
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</pre></section><h3>관련 링크</h3><ul><li><a href="https://brunch.co.kr/@mystoryg/156">Youtube Data API 키 발급 방법 안내</a></li></ul></section><section><h2>(3) 바른 API KEY 발급</h2><h3>관련 링크</h3><ul><li><a href="https://nara-speller.co.kr/speller/">하나씩 검사하기 - 바른한글</a></li><li><a href="https://bareun.ai/api-key">API로 대량 검사하기 - 바른AI</a></li></ul></section><section><h2>(4) 민기쌤 생기부 도우미</h2><section><h3>Code.gs</h3><pre>// =================================================================
// [0] 초기 설정 및 메뉴
// =================================================================

function onOpen() {
  SpreadsheetApp.getUi().createMenu(&#39;민기쌤 생기부 도우미&#39;)
      .addItem(&#39;🚀 사이드바 열기&#39;, &#39;openSidebar&#39;) 
      .addToUi();
}

function renderView_(viewName) {
  var template = HtmlService.createTemplateFromFile(&#39;Sidebar&#39;);
  template.initialView = viewName || &#39;sidebar&#39;;
  return template.evaluate();
}

function openSidebar() {
  ensureBannedWordSheet_();
  var html = renderView_(&#39;sidebar&#39;)
      .setTitle(&#39;민기쌤 생기부 맞춤법 도우미&#39;)
      .setWidth(300); 
  SpreadsheetApp.getUi().showSidebar(html);
}

function openSpellCheckViewer() {
  var html = renderView_(&#39;spellcheck&#39;)
      .setWidth(1000) 
      .setHeight(700);
  SpreadsheetApp.getUi().showModelessDialog(html, &#39;📝 Bareun 맞춤법 검사 및 수정&#39;);
}

function openBannedWordViewer() {
  var html = renderView_(&#39;banned&#39;)
      .setWidth(1000)
      .setHeight(700);
  SpreadsheetApp.getUi().showModelessDialog(html, &#39;🚫 기재 금지어 점검&#39;);
}

// =================================================================
// [1] Bareun API 키 및 UI 설정
// =================================================================

function getBareunUserProperties_() {
  ensureBannedWordSheet_();
  var props = PropertiesService.getUserProperties();
  var currentPolicy = &#39;BAREUN_ONLY_KEYS_V3&#39;;
  var previousPolicy = props.getProperty(&#39;API_KEY_POLICY_VERSION&#39;);

  // 정책 변경 시 현재 기능에서 쓰지 않는 예전 사용자 설정을 한 번 정리합니다.
  if (previousPolicy !== currentPolicy) {
    var allowedProperties = {
      UI_STATE: true,
      API_KEY_POLICY_VERSION: true
    };
    // 바로 이전 사용자 입력형 정책에서 저장한 키만 보존합니다.
    if (previousPolicy === &#39;USER_SUPPLIED_KEYS_V1&#39; ||
        previousPolicy === &#39;BAREUN_ONLY_KEYS_V1&#39; ||
        previousPolicy === &#39;BAREUN_ONLY_KEYS_V2&#39;) {
      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(&#39;UI_STATE&#39;) || &#39;{}&#39;);
    } catch (e) {
      previousUiState = {};
    }
    var currentUiState = {};
    [&#39;card-api&#39;, &#39;sub-api-settings&#39;, &#39;card-util&#39;].forEach(function(id) {
      if (previousUiState[id]) currentUiState[id] = true;
    });
    props.setProperty(&#39;UI_STATE&#39;, JSON.stringify(currentUiState));
    props.setProperty(&#39;API_KEY_POLICY_VERSION&#39;, currentPolicy);
  }

  return props;
}

function saveUserBareunKey(bareunKey) {
  var props = getBareunUserProperties_();
  var cleanBareunKey = String(bareunKey || &#39;&#39;).trim();

  if (!cleanBareunKey) {
    throw new Error(&#39;Bareun API 키를 입력해 주세요. 이 도우미는 API 키를 제공하지 않습니다.&#39;);
  }

  props.setProperty(&#39;USER_BAREUN_KEY&#39;, cleanBareunKey);
  return { hasBareun: true };
}

function checkUserBareunStatus() {
  var props = getBareunUserProperties_();
  return { hasBareun: !!props.getProperty(&#39;USER_BAREUN_KEY&#39;) };
}

function clearUserBareunKey() {
  var props = getBareunUserProperties_();
  props.deleteProperty(&#39;USER_BAREUN_KEY&#39;);
  return { hasBareun: false };
}

function getBareunKey() {
  var key = getBareunUserProperties_().getProperty(&#39;USER_BAREUN_KEY&#39;);
  if (!key) throw new Error(&quot;Bareun API 키가 없습니다. 사이드바 상단에서 등록해주세요.&quot;);
  return key;
}

// =================================================================
// [2] Bareun 맞춤법 검사
// =================================================================

function getSelectedDataForSpellCheck() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var sheetName = sheet.getName();
  var range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);
  var rows = [];
  var startRow = range.getRow();
  var startCol = range.getColumn();
  var values = range.getValues();
  for (var i = 0; i &lt; values.length; i++) {
    for (var j = 0; j &lt; values[0].length; j++) {
      var text = values[i][j].toString();
      if (text.trim() !== &quot;&quot;) {
        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(&#39;검사한 시트를 찾을 수 없습니다: &#39; + item.sheetName);
    sheet.getRange(item.row, item.col).setValue(item.text);
  });
  return updates.length;
}

function callBareunApi(text, userKey) {
  var url = &quot;https://api.bareun.ai/bareun.RevisionService/CorrectError&quot;;
  var options = {
    &quot;method&quot;: &quot;post&quot;, &quot;contentType&quot;: &quot;application/json&quot;, &quot;headers&quot;: { &quot;api-key&quot;: userKey }, &quot;muteHttpExceptions&quot;: true,
    &quot;payload&quot;: JSON.stringify({ &quot;document&quot;: { &quot;content&quot;: text, &quot;language&quot;: &quot;ko-KR&quot; }, &quot;encoding_type&quot;: &quot;UTF16&quot; })
  };
  var response = UrlFetchApp.fetch(url, options);
  var result = JSON.parse(response.getContentText());
  
  if (response.getResponseCode() !== 200) {
    return { error: &quot;API 오류: &quot; + response.getResponseCode() };
  }
  return { revised: result.revised, helps: result.helps || {}, rawBlocks: result.revisedBlocks || result.revised_blocks || [], error: null };
}

// =================================================================
// [3] 기재 금지어 관리
// =================================================================

var BANNED_WORD_SHEET_NAME_ = &#39;기재금지어관리&#39;;
var BANNED_WORD_HEADERS_ = [&#39;유형&#39;, &#39;기재 금지어&#39;, &#39;대체 표현&#39;];

function getLegacyBannedWordList_() {
  var saved = PropertiesService.getUserProperties().getProperty(&#39;BANNED_WORDS_DB&#39;);
  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 &amp;&amp; item.k || &#39;&#39;).trim(),
          v: String(item &amp;&amp; item.v || &#39;&#39;).trim(),
          t: item &amp;&amp; item.t === &#39;ban&#39; ? &#39;ban&#39; : &#39;replace&#39;
        };
      })
      .filter(function(item) { return item.k !== &#39;&#39;; });
  } catch (e) {
    return getDefaultBannedList_();
  }
}

function initializeBannedWordSheet_(sheet, list) {
  sheet.getRange(1, 1, 1, BANNED_WORD_HEADERS_.length)
    .setValues([BANNED_WORD_HEADERS_])
    .setBackground(&#39;#fce8e6&#39;)
    .setFontColor(&#39;#a50e0e&#39;)
    .setFontWeight(&#39;bold&#39;)
    .setHorizontalAlignment(&#39;center&#39;);

  var rows = list.map(function(item) {
    return [item.t === &#39;ban&#39; ? &#39;불가&#39; : &#39;대체&#39;, item.k, item.t === &#39;ban&#39; ? &#39;&#39; : item.v];
  });
  if (rows.length &gt; 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(&#39;#d93025&#39;);

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

function ensureBannedWordSheet_() {
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  if (!spreadsheet) throw new Error(&#39;활성 스프레드시트를 찾을 수 없습니다.&#39;);

  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(&#39;BANNED_WORDS_DB&#39;);
    return sheet;
  } finally {
    if (originalSheet &amp;&amp; sheet &amp;&amp; originalSheet.getSheetId() !== sheet.getSheetId()) {
      spreadsheet.setActiveSheet(originalSheet);
      if (originalRange) spreadsheet.setActiveRange(originalRange);
    }
    lock.releaseLock();
  }
}

function openBannedWordManagementSheet() {
  var sheet = ensureBannedWordSheet_();
  sheet.activate();
  sheet.getRange(&#39;A1&#39;).activate();
  return sheet.getName();
}

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

function getBannedWordList_() {
  var sheet = ensureBannedWordSheet_();
  var header = sheet.getRange(1, 1, 1, 3).getDisplayValues()[0];
  if (header.join(&#39;|&#39;) !== BANNED_WORD_HEADERS_.join(&#39;|&#39;)) {
    throw new Error(&#39;기재금지어관리 시트의 1행을 &quot;유형 | 기재 금지어 | 대체 표현&quot; 순서로 유지해 주세요.&#39;);
  }

  var lastRow = sheet.getLastRow();
  if (lastRow &lt; 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] || &#39;&#39;).trim();
    var keyword = String(row[1] || &#39;&#39;).trim();
    var replacement = String(row[2] || &#39;&#39;).trim();

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

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

  return list;
}

function runBannedWordCheck() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var sheetName = sheet.getName();
  var range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);

  var values = range.getValues();
  var startRow = range.getRow();
  var startCol = range.getColumn();
  
  var db = getBannedWordList_();
  db.sort((a, b) =&gt; b.k.length - a.k.length);

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

  var results = [];

  for (var i = 0; i &lt; values.length; i++) {
    for (var j = 0; j &lt; 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 &lt; patterns.length; p++) {
        var item = patterns[p];
        if (item.regex.test(revised)) {
          hasIssue = true;
          var alreadyLogged = false;
          for(var l=0; l&lt;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 === &quot;replace&quot;) {
            revised = revised.replace(item.regex, item.v);
          } else if (item.t === &quot;ban&quot;) {
            revised = revised.replace(item.regex, &quot;&quot;); 
          }
        }
      }
      
      revised = revised.replace(/\s+/g, &#39; &#39;).trim();

      if (hasIssue &amp;&amp; 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, &#39;\\$&amp;&#39;); 
}

// =================================================================
// [4] 로컬 검토 유틸리티
// =================================================================

function unifyToStraightQuotes() {
  var range = SpreadsheetApp.getActiveRange();
  var ui = SpreadsheetApp.getUi();
  if (!range) throw new Error(&#39;선택된 셀이 없습니다.&#39;);
  var response = ui.alert(&#39;따옴표 변환&#39;, &#39;선택 영역의 둥근 따옴표를 곧은 따옴표로 변환하시겠습니까?&#39;, ui.ButtonSet.YES_NO);
  if (response != ui.Button.YES) return;
  try {
    range.createTextFinder(&#39;[‘’]&#39;).useRegularExpression(true).matchEntireCell(false).replaceAllWith(&quot;&#39;&quot;);
    range.createTextFinder(&#39;[“”]&#39;).useRegularExpression(true).matchEntireCell(false).replaceAllWith(&#39;&quot;&#39;);
    SpreadsheetApp.flush();
    ui.alert(&#39;✅ 변환 완료&#39;);
  } catch (e) { ui.alert(&#39;오류: &#39; + e.toString()); }
}

// [신규] 선택 영역 스페이스바 실수(2칸 이상) 수정 함수
function fixDoubleSpaces() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);

  const values = range.getValues();
  let fixCount = 0; // 수정된 셀 개수 카운트

  const newValues = values.map(row =&gt; {
    return row.map(cell =&gt; {
      let str = String(cell);
      // 공백이 2번 이상 연속( /  +/)으로 있으면 1번으로 변경
      // 줄바꿈(\n)은 건드리지 않도록 일반 공백( )만 타겟팅
      if (/ {2,}/.test(str)) {
        fixCount++;
        return str.replace(/ {2,}/g, &#39; &#39;); 
      }
      return cell;
    });
  });

  if (fixCount &gt; 0) {
    range.setValues(newValues);
  }
  
  return fixCount;
}

// =================================================================
// [5] UI 상태 저장/불러오기
// =================================================================

function saveUiState(cardId, isExpanded) {
  var props = PropertiesService.getUserProperties();
  var state = JSON.parse(props.getProperty(&#39;UI_STATE&#39;) || &#39;{}&#39;);
  state[cardId] = isExpanded;
  props.setProperty(&#39;UI_STATE&#39;, JSON.stringify(state));
}

function getUiState() {
  var props = PropertiesService.getUserProperties();
  return JSON.parse(props.getProperty(&#39;UI_STATE&#39;) || &#39;{}&#39;);
}

</pre></section><section><h3>Sidebar.html</h3><pre>&lt;? var allowedViews = [&quot;sidebar&quot;, &quot;spellcheck&quot;, &quot;banned&quot;]; ?&gt;
&lt;? var activeView = allowedViews.indexOf(initialView) &gt;= 0 ? initialView : &quot;sidebar&quot;; ?&gt;
&lt;? if (activeView === &#39;sidebar&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;style&gt;
      * { box-sizing: border-box; }
      body {
        margin: 0;
        padding: 10px 15px 10px 2px;
        background: #f1f3f4;
        color: #202124;
        font-family: &#39;Noto Sans KR&#39;, 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=&quot;password&quot;] {
        width: 100%;
        padding: 10px;
        border: 2px solid #dadce0;
        border-radius: 6px;
        outline: none;
        font-family: inherit;
        font-size: 14px;
      }
      input[type=&quot;password&quot;]: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; }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div class=&quot;card&quot; id=&quot;card-api&quot;&gt;
      &lt;div class=&quot;card-header&quot; onclick=&quot;toggleCard(this)&quot;&gt;
        &lt;div class=&quot;header-title&quot;&gt;🔑 Bareun API 키 설정&lt;/div&gt;
        &lt;span class=&quot;material-icons toggle-icon&quot;&gt;expand_more&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class=&quot;card-body&quot;&gt;
        &lt;div class=&quot;sub-section&quot; id=&quot;sub-api-settings&quot;&gt;
          &lt;div class=&quot;sub-header&quot; onclick=&quot;toggleSub(this)&quot;&gt;
            &lt;span&gt;내 Bareun API 키&lt;/span&gt;
            &lt;span class=&quot;material-icons sub-icon&quot;&gt;expand_more&lt;/span&gt;
          &lt;/div&gt;
          &lt;div class=&quot;sub-body&quot;&gt;
            &lt;div id=&quot;api-loading&quot; class=&quot;api-status&quot;&gt;⌛ API 키 설정 확인 중...&lt;/div&gt;
            &lt;div id=&quot;api-settings&quot; class=&quot;hidden&quot;&gt;
              &lt;div id=&quot;apiStatusSummary&quot; class=&quot;api-status&quot;&gt;&lt;/div&gt;
              &lt;label class=&quot;input-label&quot; for=&quot;bareunKeyInput&quot;&gt;Bareun API 키&lt;/label&gt;
              &lt;input type=&quot;password&quot; id=&quot;bareunKeyInput&quot; autocomplete=&quot;off&quot; placeholder=&quot;본인이 발급받은 키를 입력하세요&quot;&gt;
              &lt;div class=&quot;key-help&quot;&gt;공용 키는 제공하지 않습니다. 키는 사용자별 설정에 저장되고 원문을 화면으로 다시 불러오지 않습니다.&lt;/div&gt;
              &lt;button class=&quot;btn-auth&quot; id=&quot;saveKeyBtn&quot; onclick=&quot;saveBareunKey()&quot;&gt;내 Bareun 키 저장&lt;/button&gt;
              &lt;button class=&quot;btn-secondary hidden&quot; id=&quot;clearKeyBtn&quot; onclick=&quot;resetBareunKey()&quot;&gt;저장된 Bareun 키 삭제&lt;/button&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;card expanded&quot; id=&quot;card-util&quot;&gt;
      &lt;div class=&quot;card-header&quot; onclick=&quot;toggleCard(this)&quot;&gt;
        &lt;div class=&quot;header-title&quot;&gt;📝 선택 영역 검사 및 정리&lt;/div&gt;
        &lt;span class=&quot;material-icons toggle-icon&quot;&gt;expand_more&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class=&quot;card-body&quot;&gt;
        &lt;button class=&quot;btn-warning&quot; id=&quot;checkBtn&quot; onclick=&quot;runSpellCheck()&quot;&gt;선택 영역 Bareun 맞춤법 검사 🔍&lt;/button&gt;
        &lt;div id=&quot;checkStatus&quot; class=&quot;status-box&quot;&gt;&lt;/div&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;runSpaceFix(this)&quot;&gt;선택 영역 연속 공백 정리&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;convertQuotes(this)&quot;&gt;선택 영역 따옴표 통일 (&#39; &quot;)&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;runBannedCheck()&quot;&gt;선택 영역 기재 금지어 검사&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;openBannedWordManagement()&quot;&gt;기재금지어관리 시트 열기&lt;/button&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let currentApiStatus = { hasBareun: false };

      function updateApiState(status) {
        currentApiStatus = { hasBareun: !!(status &amp;&amp; status.hasBareun) };

        const summary = document.getElementById(&#39;apiStatusSummary&#39;);
        const clearButton = document.getElementById(&#39;clearKeyBtn&#39;);
        const spellCheckButton = document.getElementById(&#39;checkBtn&#39;);
        const apiCard = document.getElementById(&#39;card-api&#39;);
        const apiSub = document.getElementById(&#39;sub-api-settings&#39;);

        summary.innerHTML = currentApiStatus.hasBareun
          ? &#39;&lt;div class=&quot;ok&quot;&gt;✅ Bareun 키 저장됨&lt;/div&gt;&#39;
          : &#39;&lt;div class=&quot;missing&quot;&gt;❌ 맞춤법 검사를 사용하려면 Bareun 키가 필요합니다.&lt;/div&gt;&#39;;
        clearButton.classList.toggle(&#39;hidden&#39;, !currentApiStatus.hasBareun);
        spellCheckButton.disabled = !currentApiStatus.hasBareun;
        spellCheckButton.title = currentApiStatus.hasBareun ? &#39;&#39; : &#39;Bareun API 키를 먼저 저장해 주세요.&#39;;

        apiCard.classList.toggle(&#39;expanded&#39;, !currentApiStatus.hasBareun);
        apiSub.classList.toggle(&#39;expanded&#39;, !currentApiStatus.hasBareun);
      }

      function toggleCard(header) {
        const card = header.parentElement;
        const isExpanded = card.classList.toggle(&#39;expanded&#39;);
        google.script.run.saveUiState(card.id, isExpanded);
      }

      function toggleSub(header) {
        const section = header.parentElement;
        const isExpanded = section.classList.toggle(&#39;expanded&#39;);
        google.script.run.saveUiState(section.id, isExpanded);
      }

      function saveBareunKey() {
        const button = document.getElementById(&#39;saveKeyBtn&#39;);
        const keyInput = document.getElementById(&#39;bareunKeyInput&#39;);
        const key = keyInput.value.trim();
        if (!key) {
          alert(&#39;Bareun API 키를 입력해 주세요.&#39;);
          return;
        }

        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;저장 중...&#39;;

        google.script.run
          .withSuccessHandler(function(status) {
            keyInput.value = &#39;&#39;;
            updateApiState(status);
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;✅ Bareun API 키를 사용자 설정에 저장했습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;Bareun API 키 저장 오류: &#39; + error);
          })
          .saveUserBareunKey(key);
      }

      function resetBareunKey() {
        if (!confirm(&#39;저장된 Bareun API 키를 삭제하시겠습니까?&#39;)) return;

        google.script.run
          .withSuccessHandler(function(status) {
            document.getElementById(&#39;bareunKeyInput&#39;).value = &#39;&#39;;
            updateApiState(status);
            alert(&#39;저장된 Bareun API 키를 삭제했습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            alert(&#39;Bareun API 키 삭제 오류: &#39; + error);
          })
          .clearUserBareunKey();
      }

      function runSpellCheck() {
        if (!currentApiStatus.hasBareun) {
          alert(&#39;Bareun API 키를 먼저 저장해 주세요.&#39;);
          return;
        }
        document.getElementById(&#39;checkStatus&#39;).innerText = &#39;맞춤법 검사 창을 여는 중...&#39;;
        google.script.run
          .withSuccessHandler(function() {
            document.getElementById(&#39;checkStatus&#39;).innerText = &#39;&#39;;
          })
          .withFailureHandler(function(error) {
            document.getElementById(&#39;checkStatus&#39;).innerText = &#39;&#39;;
            alert(&#39;오류 발생: &#39; + error);
          })
          .openSpellCheckViewer();
      }

      function runSpaceFix(button) {
        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;정리 중...&#39;;
        google.script.run
          .withSuccessHandler(function(count) {
            button.disabled = false;
            button.innerText = originalText;
            alert(count ? &#39;✅ 총 &#39; + count + &#39;개 셀의 연속 공백을 정리했습니다.&#39; : &#39;수정할 연속 공백이 없습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;오류 발생: &#39; + error);
          })
          .fixDoubleSpaces();
      }

      function convertQuotes(button) {
        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;변환 중...&#39;;
        google.script.run
          .withSuccessHandler(function() {
            button.disabled = false;
            button.innerText = originalText;
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;오류 발생: &#39; + error);
          })
          .unifyToStraightQuotes();
      }

      function runBannedCheck() {
        google.script.run
          .withFailureHandler(function(error) { alert(&#39;오류 발생: &#39; + error); })
          .openBannedWordViewer();
      }

      function openBannedWordManagement() {
        google.script.run
          .withFailureHandler(function(error) { alert(&#39;오류 발생: &#39; + error); })
          .openBannedWordManagementSheet();
      }

      window.onload = function() {
        google.script.run
          .withSuccessHandler(function(status) {
            document.getElementById(&#39;api-loading&#39;).classList.add(&#39;hidden&#39;);
            document.getElementById(&#39;api-settings&#39;).classList.remove(&#39;hidden&#39;);
            updateApiState(status);

            google.script.run.withSuccessHandler(function(states) {
              Object.keys(states || {}).forEach(function(id) {
                const element = document.getElementById(id);
                if (id === &#39;card-api&#39; || id === &#39;sub-api-settings&#39;) return;
                if (element &amp;&amp; states[id]) element.classList.add(&#39;expanded&#39;);
              });
              updateApiState(status);
            }).getUiState();
          })
          .withFailureHandler(function(error) {
            document.getElementById(&#39;api-loading&#39;).classList.add(&#39;hidden&#39;);
            document.getElementById(&#39;api-settings&#39;).classList.remove(&#39;hidden&#39;);
            updateApiState({ hasBareun: false });
            document.getElementById(&#39;apiStatusSummary&#39;).textContent = &#39;설정 확인 오류: &#39; + error;
          })
          .checkUserBareunStatus();
      };
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } else if (activeView === &#39;spellcheck&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch.js&quot;&gt;&lt;/script&gt;
    &lt;style&gt;
      body { padding: 20px; font-family: &#39;Noto Sans KR&#39;, 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; }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;

    &lt;div class=&quot;header-area&quot;&gt;
      &lt;div class=&quot;title&quot;&gt;&lt;span class=&quot;material-icons&quot;&gt;fact_check&lt;/span&gt; 맞춤법 검사 및 수정&lt;/div&gt;
      &lt;div style=&quot;font-size:12px; color:#5f6368; display:flex; gap:15px;&quot;&gt;
        &lt;div&gt;&lt;span style=&quot;background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;&quot;&gt;원본 오류&lt;/span&gt;&lt;/div&gt;
        &lt;div&gt;&lt;span style=&quot;background:#e6f4ea; color:#188038; border-bottom:2px solid #188038; padding:0 4px; font-weight:500;&quot;&gt;수정 제안&lt;/span&gt;&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div id=&quot;mainContent&quot; class=&quot;content-area&quot;&gt;
      &lt;div id=&quot;loaderOverlay&quot; class=&quot;loader-overlay&quot;&gt;
        &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
        &lt;div id=&quot;loadingText&quot;&gt;데이터를 불러오는 중입니다...&lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;cardsContainer&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;footer-bar&quot;&gt;
      &lt;button class=&quot;btn btn-cancel&quot; onclick=&quot;google.script.host.close()&quot;&gt;닫기&lt;/button&gt;
      &lt;button class=&quot;btn btn-apply&quot; id=&quot;btnApply&quot; onclick=&quot;applyAll()&quot; disabled&gt;적용하기 (0건)&lt;/button&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let itemData = []; 
      let isFirstRender = true; 

      window.onload = function() {
        google.script.run
          .withSuccessHandler(processData)
          .withFailureHandler(function(err) {
             document.getElementById(&#39;loaderOverlay&#39;).innerHTML = 
               `&lt;div class=&quot;error-msg&quot;&gt;❌ 초기화 오류&lt;br&gt;${err}&lt;br&gt;선택된 셀이 있는지 확인해주세요.&lt;/div&gt;`;
          })
          .getSelectedDataForSpellCheck();
      };

      function processData(rows) {
        if (rows.length === 0) {
          document.getElementById(&#39;loaderOverlay&#39;).innerHTML = &#39;&lt;div class=&quot;error-msg&quot;&gt;선택된 셀이 없거나 빈 셀입니다.&lt;/div&gt;&#39;;
          return;
        }

        document.getElementById(&#39;loadingText&#39;).innerText = `총 ${rows.length}개 항목 분석 중...`;
        const container = document.getElementById(&#39;cardsContainer&#39;);
        container.innerHTML = &quot;&quot;; 
        
        let processedCount = 0;
        const total = rows.length;

        rows.forEach((row, index) =&gt; {
          const div = document.createElement(&#39;div&#39;);
          div.id = `item-${index}`;
          div.className = &#39;check-item&#39;;
          div.innerHTML = `
            &lt;div class=&quot;item-header&quot;&gt;&lt;span&gt;행 ${row.row} (대기 중...)&lt;/span&gt;&lt;/div&gt;
            &lt;div class=&quot;split-view&quot;&gt;&lt;div class=&quot;pane pane-left&quot;&gt;...&lt;/div&gt;&lt;div class=&quot;pane pane-right&quot;&gt;...&lt;/div&gt;&lt;/div&gt;
          `;
          container.appendChild(div);

          google.script.run
            .withSuccessHandler((res) =&gt; {
              renderResult(index, row, res);
              processedCount++;
              updateApplyButton(processedCount, total);
            })
            .withFailureHandler((err) =&gt; {
              renderError(index, row, err);
              processedCount++;
              updateApplyButton(processedCount, total);
            })
            .getBareunCorrection(row.original);
        });
      }

      function renderError(index, rowData, errorMsg) {
        if (isFirstRender) {
          document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
          isFirstRender = false;
        }
        const itemDiv = document.getElementById(`item-${index}`);
        if (!itemDiv) return;
        itemDiv.innerHTML = `&lt;div style=&quot;padding:20px; color:red;&quot;&gt;❌ 분석 실패: ${errorMsg}&lt;/div&gt;`;
      }

      function renderResult(index, rowData, apiResult) {
        if (isFirstRender) {
          document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
          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 = &quot;&quot;;
        const analysisList = [];
        if (apiResult.rawBlocks &amp;&amp; apiResult.rawBlocks.length &gt; 0) {
          traverseBlocks(apiResult.rawBlocks, apiResult.helps, analysisList);
        }

        if (analysisList.length &gt; 0) {
          const listItems = analysisList.map(item =&gt; `
            &lt;li class=&quot;analysis-row&quot;&gt;
              &lt;span class=&quot;err-word&quot;&gt;${item.origin}&lt;/span&gt;
              &lt;span class=&quot;arrow&quot;&gt;→&lt;/span&gt;
              &lt;span class=&quot;fix-word&quot;&gt;${item.revised}&lt;/span&gt;
              &lt;span class=&quot;fix-reason&quot;&gt;${item.msg}&lt;/span&gt;
            &lt;/li&gt;
          `).join(&quot;&quot;);

          helpListHtml = `
            &lt;div class=&quot;analysis-box&quot;&gt;
              &lt;div class=&quot;analysis-title&quot;&gt;&lt;span class=&quot;material-icons&quot; style=&quot;font-size:14px;&quot;&gt;info&lt;/span&gt; 상세 수정 내역 (${analysisList.length}건)&lt;/div&gt;
              &lt;ul class=&quot;analysis-list&quot;&gt;${listItems}&lt;/ul&gt;
            &lt;/div&gt;
          `;
        }

        itemDiv.innerHTML = `
          &lt;div class=&quot;item-header&quot;&gt;
            &lt;span&gt;행 ${rowData.row}&lt;/span&gt;
            &lt;span style=&quot;color:#1a73e8;&quot;&gt;분석 완료&lt;/span&gt;
          &lt;/div&gt;
          &lt;div class=&quot;split-view&quot;&gt;
            &lt;div class=&quot;pane pane-left&quot;&gt;${diff.left}&lt;/div&gt;
            &lt;div class=&quot;pane pane-right&quot; contenteditable=&quot;true&quot; spellcheck=&quot;false&quot; id=&quot;edit-${index}&quot; oninput=&quot;updateBytes(${index})&quot;&gt;${diff.right}&lt;/div&gt;
          &lt;/div&gt;
          ${helpListHtml}
          &lt;div class=&quot;byte-info&quot;&gt;
            &lt;div class=&quot;byte-box&quot; style=&quot;border-right:1px solid #eee;&quot;&gt;원본: ${getByteLength(rowData.original)} byte&lt;/div&gt;
            &lt;div class=&quot;byte-box&quot;&gt;수정본: &lt;span id=&quot;byte-${index}&quot; class=&quot;byte-val&quot;&gt;${getByteLength(apiResult.revised)}&lt;/span&gt; byte&lt;/div&gt;
          &lt;/div&gt;
        `;
        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 &amp;&amp; block.nested.length &gt; 0) {
            traverseBlocks(block.nested, helps, resultsArray);
          } else {
            const origin = block.origin.content || &quot;&quot;;
            const revised = block.revised || &quot;&quot;;
            if (origin === revised) return;
            let msg = &quot;교정&quot;;
            if (block.revisions &amp;&amp; block.revisions.length &gt; 0) {
              const helpId = block.revisions[0].helpId || block.revisions[0].help_id;
              if (helps &amp;&amp; !helps[helpId]) return;
              if (helpId &amp;&amp; helps &amp;&amp; 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 === &#39;undefined&#39;) {
           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 = &quot;&quot;;
          for (var i = 0; i &lt; 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 = &quot;&quot;;
        let rightHtml = &quot;&quot;;
        
        diffs.forEach(function(part) {
          var op = part[0]; 
          var chars = part[1];
          var text = &quot;&quot;;
          
          // 유니코드를 다시 원래 단어로 복원
          for (var i = 0; i &lt; chars.length; i++) {
            text += tokenArr[chars.charCodeAt(i)];
          }
          
          if (op === 0) {
            leftHtml += text; rightHtml += text;
          } else if (op === -1) {
            leftHtml += `&lt;span class=&quot;diff-del&quot;&gt;${text}&lt;/span&gt;`;
          } else if (op === 1) {
            rightHtml += `&lt;span class=&quot;diff-add&quot;&gt;${text}&lt;/span&gt;`;
          }
        });
        
        return { left: leftHtml, right: rightHtml };
      }

      function getByteLength(s) {
        let b = 0; if (!s) return 0;
        for (let i = 0; i &lt; s.length; i++) {
          const c = s.charCodeAt(i);
          if (c === 10) b += 2; else if (c &gt;&gt; 11) b += 3; else if (c &gt;&gt; 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(&#39;btnApply&#39;);
        btn.innerText = `적용하기 (${current} / ${total})`;
        if (current === total) btn.disabled = false;
      }

      function applyAll() {
        if (!confirm(&quot;검사된 내용으로 원본 데이터를 덮어쓰시겠습니까?\n(되돌릴 수 없습니다)&quot;)) return;
        const updates = [];
        itemData.forEach((item, index) =&gt; {
          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(&#39;btnApply&#39;);
        btn.disabled = true; btn.innerText = &quot;저장 중...&quot;;
        google.script.run.withSuccessHandler((count) =&gt; {
          alert(`✅ 총 ${count}건이 수정되었습니다.`);
          google.script.host.close();
        }).applySpellCheckResults(updates);
      }
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } else if (activeView === &#39;banned&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;style&gt;
      body { padding: 20px; font-family: &#39;Noto Sans KR&#39;, 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: &quot;•&quot;; 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); } }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;

    &lt;div class=&quot;header-area&quot;&gt;
      &lt;div class=&quot;title&quot;&gt;&lt;span class=&quot;material-icons&quot;&gt;block&lt;/span&gt; 기재 금지어 검사&lt;/div&gt;
      &lt;div id=&quot;checkLegend&quot; style=&quot;font-size:12px; color:#5f6368; display:flex; gap:15px;&quot;&gt;
        &lt;div&gt;&lt;span style=&quot;background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;&quot;&gt;금지어 발견&lt;/span&gt;&lt;/div&gt;
        &lt;div&gt;&lt;span style=&quot;background:#e6f4ea; color:#188038; border-bottom:2px solid #188038; padding:0 4px; font-weight:500;&quot;&gt;대체어 제안&lt;/span&gt;&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div id=&quot;checkView&quot; class=&quot;content-area&quot;&gt;
      &lt;div id=&quot;loaderOverlay&quot; class=&quot;loader-overlay&quot;&gt;
        &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
        &lt;div id=&quot;loadingText&quot;&gt;데이터를 분석 중입니다...&lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;cardsContainer&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;footer-bar&quot; id=&quot;footerCheck&quot;&gt;
      &lt;button class=&quot;btn btn-cancel&quot; onclick=&quot;google.script.host.close()&quot;&gt;닫기&lt;/button&gt;
      &lt;button class=&quot;btn btn-apply&quot; id=&quot;btnApply&quot; onclick=&quot;applyAll()&quot; disabled&gt;적용하기 (0건)&lt;/button&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let itemData = []; 
      let renderQueue = [];
      const CHUNK_SIZE = 20;

      window.onload = function() {
        runCheck();
      };

      function runCheck() {
        document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;flex&#39;;
        document.getElementById(&#39;loadingText&#39;).innerText = &quot;데이터를 분석 중입니다...&quot;;
        document.getElementById(&#39;cardsContainer&#39;).innerHTML = &quot;&quot;;
        
        google.script.run
          .withSuccessHandler(startRenderSequence)
          .withFailureHandler(err =&gt; alert(&quot;오류: &quot; + err))
          .runBannedWordCheck();
      }

      function startRenderSequence(rows) {
        document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
        const container = document.getElementById(&#39;cardsContainer&#39;);
        
        if (!rows || rows.length === 0) {
          container.innerHTML = &#39;&lt;div style=&quot;text-align:center; padding:50px; color:#5f6368;&quot;&gt;금지어가 발견되지 않았습니다. 🎉&lt;/div&gt;&#39;;
          document.getElementById(&#39;btnApply&#39;).disabled = true;
          return;
        }

        itemData = rows; 
        renderQueue = [...rows]; 
        processRenderQueue(); 

        document.getElementById(&#39;btnApply&#39;).innerText = `적용하기 (${rows.length}건)`;
        document.getElementById(&#39;btnApply&#39;).disabled = false;
      }

      function processRenderQueue() {
        if (renderQueue.length === 0) return;

        const container = document.getElementById(&#39;cardsContainer&#39;);
        const chunk = renderQueue.splice(0, CHUNK_SIZE);
        
        chunk.forEach((row) =&gt; {
          const originalIndex = itemData.findIndex(r =&gt; r.row === row.row &amp;&amp; r.col === row.col);
          const diff = simpleDiff(row.original, row.revised);
          
          // [신규] 로그 설명 생성
          let logHtml = &quot;&quot;;
          if (row.logs &amp;&amp; row.logs.length &gt; 0) {
            const listItems = row.logs.map(log =&gt; {
               let msg = &quot;&quot;;
               if (log.t === &#39;replace&#39;) {
                 msg = `&#39;&lt;span class=&quot;err-word&quot;&gt;${log.k}&lt;/span&gt;&#39;은(는) 사용할 수 없으므로 추천 대체어인 &#39;&lt;span class=&quot;fix-word&quot;&gt;${log.v}&lt;/span&gt;&#39;(으)로 바꾸었습니다.`;
               } else {
                 msg = `&#39;&lt;span class=&quot;err-word&quot;&gt;${log.k}&lt;/span&gt;&#39;은(는) 생기부 어디에도 기재 불가하므로 삭제해야 합니다.`;
               }
               return `&lt;li class=&quot;analysis-row&quot;&gt;&lt;span&gt;${msg}&lt;/span&gt;&lt;/li&gt;`;
            }).join(&quot;&quot;);
            
            logHtml = `
              &lt;div class=&quot;analysis-box&quot;&gt;
                &lt;ul class=&quot;analysis-list&quot;&gt;${listItems}&lt;/ul&gt;
              &lt;/div&gt;
            `;
          }

          const div = document.createElement(&#39;div&#39;);
          div.className = &#39;check-item&#39;;
          div.innerHTML = `
            &lt;div class=&quot;item-header&quot;&gt;
              &lt;span&gt;행 ${row.row}&lt;/span&gt;
              &lt;span style=&quot;color:#d93025; font-weight:bold;&quot;&gt;금지어 발견&lt;/span&gt;
            &lt;/div&gt;
            &lt;div class=&quot;split-view&quot;&gt;
              &lt;div class=&quot;pane pane-left&quot;&gt;${diff.left}&lt;/div&gt;
              &lt;div class=&quot;pane pane-right&quot; contenteditable=&quot;true&quot; id=&quot;edit-${originalIndex}&quot;&gt;${diff.right}&lt;/div&gt;
            &lt;/div&gt;
            ${logHtml}
          `;
          container.appendChild(div);
        });

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

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

      function applyAll() {
        if (!confirm(&quot;수정된 내용으로 원본을 덮어쓰시겠습니까?&quot;)) return;
        
        const updates = [];
        itemData.forEach((item, index) =&gt; {
          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) =&gt; {
           alert(`${c}건 수정 완료!`);
           google.script.host.close();
        }).applySpellCheckResults(updates);
      }

    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } ?&gt;
</pre></section></section><section><h2>(5) 설문조사, 지도안 작성</h2><h3>관련 링크</h3><ul><li><a href="https://padlet.com/kes960212/2-_-2-7yrg67k1n0tge2eb">지도안 제출 링크 → 2회차 수업 설계안 제출</a></li><li><a href="https://www.survey.co.kr/?ACCESS_KEY=6c6923abcaf64c4ba2b3&amp;grpid=&amp;UID=&amp;TYPE=1">사후 역량 진단</a></li><li><a href="https://www.survey.co.kr/?ACCESS_KEY=a94cf69c21804c59b403&amp;grpid=&amp;AREA=5">만족도 조사</a></li></ul></section>]]></description></item><item><title>2026년 인공지능 활용 선도교사 연수 (충남 천안 - 7. 19.)</title><link>https://민기.한국/63</link><guid isPermaLink="true">https://민기.한국/63</guid><pubDate>Thu, 11 Jun 2026 11:08:50 GMT</pubDate><description><![CDATA[<h1>2026년 인공지능 활용 선도교사 연수 (충남 천안 - 7. 19.)</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-07-19</dd><dt>장소</dt><dd>신라스테이 천안</dd></dl><section><h2>클로바노트 음성 전사</h2><h3>관련 링크</h3><ul><li><a href="https://clovanote.naver.com/w/GL2ZnyzYqzF59Bi2XCsw/home">네이버 클로바노트</a></li><li><a href="https://drive.google.com/file/d/1bTxR-DyK3I4i1ZRCdyVb_lWFEobrtOKe/view">실습용 녹음 파일</a></li></ul></section><section><h2>유튜브 댓글 수집</h2><p>아래 블로그의 설명에 따라 Youtube Data API 키를 발급합니다. 어려운 경우 최후의 수단으로 다음 연수용 API KEY를 사용할 수 있습니다. 이 KEY는 오늘 이후 폐기 처리되므로 유튜브 댓글 수집기가 더이상 작동하지 않습니다. AIzaSyDETqF5gzbFh3DG4U0N-rjT1yK2asM-1X4</p><section><h3>Code.gs</h3><pre>const YT_CFG = Object.freeze({
  API_BASE: &#39;https://www.googleapis.com/youtube/v3&#39;,

  API_KEY_PROP: &#39;YT_API_KEY&#39;,
  STATE_PROP: &#39;YT_COMMENT_COLLECTOR_STATE&#39;,
  LAST_RESULT_PROP: &#39;YT_COMMENT_COLLECTOR_LAST_RESULT&#39;,
  CANCEL_REQUEST_PROP: &#39;YT_COMMENT_COLLECTOR_CANCEL_REQUEST&#39;,

  OUTPUT_SHEET_PREFIX: &#39;시트&#39;,
  INTERNAL_SHEET: &#39;_댓글수집_내부&#39;,
  LEGACY_LOG_SHEET: &#39;수집로그&#39;,

  CONTINUE_HANDLER: &#39;continueCommentCollection&#39;,
  STRUCTURED_ERROR_PREFIX: &#39;YT_STRUCTURED_ERROR::&#39;,
  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: [
    &#39;번호&#39;,
    &#39;채널명&#39;,
    &#39;영상 제목&#39;,
    &#39;댓글 작성자&#39;,
    &#39;댓글 내용&#39;,
    &#39;좋아요 수&#39;,
    &#39;작성 시각&#39;,
    &#39;글자수&#39;
  ],

  // 숨김 내부 시트의 열 구조입니다.
  INTERNAL_HEADERS: [
    &#39;comment_id&#39;,
    &#39;parent_comment_id&#39;,
    &#39;display_number&#39;,
    &#39;comment_type&#39;,
    &#39;reply_status&#39;,
    &#39;next_reply_index&#39;,
    &#39;total_reply_count&#39;,
    &#39;reply_page_token&#39;
  ],

  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(&#39;YouTube 댓글 수집&#39;)
    .addItem(&#39;사이드바 열기&#39;, &#39;showYouTubeCommentSidebar&#39;)
    .addSeparator()
    .addItem(&#39;중단 지점부터 실행&#39;, &#39;continueCommentCollection&#39;)
    .addItem(&#39;진행 중인 수집 취소&#39;, &#39;cancelCommentCollection&#39;)
    .addToUi();
}


/**
 * 오른쪽 사이드바를 엽니다.
 */
function showYouTubeCommentSidebar() {
  const html = HtmlService
    .createHtmlOutputFromFile(&#39;Sidebar&#39;)
    .setTitle(&#39;YouTube 댓글 수집기&#39;);

  SpreadsheetApp.getUi().showSidebar(html);
}


/**
 * 사이드바 초기 상태를 반환합니다.
 */
function getSidebarState() {
  const props = getUserProperties_();
  const apiKey = props.getProperty(YT_CFG.API_KEY_PROP) || &#39;&#39;;
  const activeState = loadState_();
  const lastResult = loadLastResult_();
  const cancelRequested =
    props.getProperty(YT_CFG.CANCEL_REQUEST_PROP) === &#39;true&#39;;

  const progress = activeState
    ? buildProgressForClient_(activeState, &#39;RUNNING&#39;)
    : lastResult
      ? buildProgressForClient_(lastResult, lastResult.status || &#39;COMPLETED&#39;)
      : null;

  if (progress &amp;&amp; activeState &amp;&amp; cancelRequested) {
    progress.stage = &#39;취소 처리 중&#39;;
    progress.message = &#39;현재 실행 구간이 끝나는 즉시 안전하게 중단됩니다.&#39;;
  }

  return {
    apiKeyConfigured: Boolean(apiKey),
    apiKeyMask: apiKey ? maskApiKey_(apiKey) : &#39;&#39;,
    running: Boolean(activeState),
    cancelRequested: cancelRequested,
    progress: progress
  };
}


/**
 * API 키를 실제 YouTube Data API 요청으로 검증한 뒤 사용자별 속성에 저장합니다.
 */
function saveApiKey(apiKey) {
  const cleanedKey = String(apiKey || &#39;&#39;).trim();

  if (!cleanedKey) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_REQUIRED&#39;,
      &#39;API 키를 입력해야 합니다.&#39;,
      &#39;Google Cloud에서 발급한 YouTube Data API 키가 입력되지 않았습니다.&#39;,
      [&#39;API 키 입력란이 비어 있습니다.&#39;],
      [
        &#39;Google Cloud Console에서 API 키를 발급합니다.&#39;,
        &#39;API 키 전체를 사이드바 입력란에 붙여 넣습니다.&#39;,
        &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 입력&#39;
      }
    ));
  }

  if (cleanedKey.length &lt; 20) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_FORMAT_INVALID&#39;,
      &#39;API 키 형식을 확인해야 합니다.&#39;,
      &#39;입력한 문자열이 일반적인 Google API 키 길이보다 짧습니다.&#39;,
      [
        &#39;API 키 일부만 복사했을 수 있습니다.&#39;,
        &#39;프로젝트 ID나 다른 값을 입력했을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;키 유형이 ‘API 키’인지 확인합니다.&#39;,
        &#39;API 키 값을 처음부터 끝까지 다시 복사하여 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 다시 입력&#39;
      }
    ));
  }

  validateApiKey_(cleanedKey);

  const props = getUserProperties_();
  props.setProperty(YT_CFG.API_KEY_PROP, cleanedKey);

  // 이전 API 키 오류 안내가 새 키 저장 뒤 다시 나타나지 않도록 정리합니다.
  const previous = loadLastResult_();
  if (previous &amp;&amp; previous.status === &#39;ERROR&#39;) {
    props.deleteProperty(YT_CFG.LAST_RESULT_PROP);
  }

  return getSidebarState();
}


/**
 * 저장된 API 키를 삭제합니다.
 */
function deleteApiKey() {
  if (loadState_()) {
    throwStructuredError_(buildErrorInfo_(
      &#39;API_KEY_DELETE_BLOCKED&#39;,
      &#39;수집 중에는 API 키를 삭제할 수 없습니다.&#39;,
      &#39;현재 댓글 수집 작업이 저장된 API 키를 사용하고 있습니다.&#39;,
      [&#39;진행 중인 작업이 존재합니다.&#39;],
      [
        &#39;현재 수집을 완료하거나 취소합니다.&#39;,
        &#39;수집 상태가 종료된 뒤 API 키를 삭제합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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_(
        &#39;SPREADSHEET_NOT_FOUND&#39;,
        &#39;댓글을 저장할 스프레드시트를 찾지 못했습니다.&#39;,
        &#39;현재 Apps Script가 스프레드시트와 연결된 상태인지 확인해야 합니다.&#39;,
        [
          &#39;독립 실행형 Apps Script 프로젝트에서 코드를 실행했을 수 있습니다.&#39;,
          &#39;스프레드시트가 닫히거나 접근 권한이 변경되었을 수 있습니다.&#39;
        ],
        [
          &#39;댓글을 저장할 Google 스프레드시트를 엽니다.&#39;,
          &#39;‘확장 프로그램 → Apps Script’에서 이 코드를 실행합니다.&#39;,
          &#39;스프레드시트를 새로고침하고 사이드바를 다시 엽니다.&#39;
        ],
        { actionLabel: &#39;확인&#39; }
      ));
    }

    // 영상과 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: &#39;RUNNING&#39;,
      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: &#39;&#39;,
      threadDone: false,

      currentReplyParentId: &#39;&#39;,
      nextReplyPageToken: &#39;&#39;,

      topCount: 0,
      replyCount: 0,
      threadPages: 0,
      replyPages: 0,
      apiCalls: 1,

      startedAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      message: outputSheet.getName() + &#39;에서 수집을 시작했습니다.&#39;,
      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 &lt; 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 || &#39;&#39;;
        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 = &#39;실행 시간을 고려하여 진행 상태를 저장했습니다.&#39;;
    saveState_(state);
    scheduleContinuation_();

  } catch (error) {
    deleteContinuationTriggers_();

    const errorInfo = normalizeErrorInfo_(error, &#39;댓글 수집&#39;);

    if (errorInfo.code === &#39;API_KEY_INVALID&#39;) {
      getUserProperties_().deleteProperty(YT_CFG.API_KEY_PROP);
    }

    if (commentsSheet) {
      try {
        finalizeCommentsSheet_(commentsSheet);
      } catch (formatError) {
        console.error(formatError);
      }
    }

    if (state) {
      state.status = &#39;ERROR&#39;;
      state.updatedAt = new Date().toISOString();
      state.message = errorInfo.summary;
      state.error = errorInfo;
      saveLastResult_(state, &#39;ERROR&#39;, 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, &#39;true&#39;);
  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 &lt;= 0) {
    state.threadDone = true;
    state.nextThreadPageToken = &#39;&#39;;
    return;
  }

  const params = {
    part: state.includeReplies ? &#39;snippet,replies&#39; : &#39;snippet&#39;,
    videoId: state.videoId,
    maxResults: Math.min(100, remaining),
    textFormat: &#39;plainText&#39;
  };

  if (state.nextThreadPageToken) {
    params.pageToken = state.nextThreadPageToken;
  }

  const data = youtubeGet_(&#39;commentThreads&#39;, 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 &gt;= 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 = &#39;DONE&#39;;
    let nextReplyIndex = 1;

    commentRows.push(
      commentToOutputRow_(state, topComment, topNumber)
    );

    batchIds.add(topCommentId);
    topAdded += 1;

    const inlineReplies =
      thread.replies &amp;&amp; Array.isArray(thread.replies.comments)
        ? thread.replies.comments
        : [];

    if (state.includeReplies &amp;&amp; totalReplyCount &gt; 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 + &#39;_&#39; + nextReplyIndex;
          nextReplyIndex += 1;

          commentRows.push(
            commentToOutputRow_(state, reply, displayNumber)
          );

          internalRows.push(
            buildInternalRow_(
              replyId,
              topCommentId,
              displayNumber,
              &#39;reply&#39;,
              &#39;&#39;,
              &#39;&#39;,
              &#39;&#39;,
              &#39;&#39;
            )
          );

          batchIds.add(replyId);
          replyAdded += 1;
        });
      } else {
        // commentThreads.list의 replies에는 전체 답글이 보장되지 않으므로
        // comments.list로 전체 답글을 다시 가져옵니다.
        replyStatus = &#39;PENDING&#39;;
        nextReplyIndex = 1;
      }
    }

    internalRows.push(
      buildInternalRow_(
        topCommentId,
        &#39;&#39;,
        topNumber,
        &#39;topLevel&#39;,
        replyStatus,
        nextReplyIndex,
        totalReplyCount,
        &#39;&#39;
      )
    );
  });

  appendCollectedRecords_(
    commentsSheet,
    internalSheet,
    commentRows,
    internalRows
  );

  batchIds.forEach(function(id) {
    existingIds.add(id);
  });

  state.topCount += topAdded;
  state.replyCount += replyAdded;

  const reachedTarget = state.topCount &gt;= state.targetTopLevelCount;
  const hasNextPage = Boolean(data.nextPageToken);

  state.nextThreadPageToken =
    !reachedTarget &amp;&amp; hasNextPage
      ? data.nextPageToken
      : &#39;&#39;;

  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 = &#39;&#39;;
    state.nextReplyPageToken = &#39;&#39;;
    return;
  }

  const params = {
    part: &#39;snippet&#39;,
    parentId: state.currentReplyParentId,
    maxResults: 100,
    textFormat: &#39;plainText&#39;
  };

  const pageToken =
    state.nextReplyPageToken || topRecord.replyPageToken || &#39;&#39;;

  if (pageToken) {
    params.pageToken = pageToken;
  }

  const data = youtubeGet_(&#39;comments&#39;, 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) + &#39;_&#39; + nextReplyIndex;

    nextReplyIndex += 1;

    commentRows.push(
      commentToOutputRow_(state, reply, displayNumber)
    );

    internalRows.push(
      buildInternalRow_(
        replyId,
        state.currentReplyParentId,
        displayNumber,
        &#39;reply&#39;,
        &#39;&#39;,
        &#39;&#39;,
        &#39;&#39;,
        &#39;&#39;
      )
    );

    batchIds.add(replyId);
    replyAdded += 1;
  });

  appendCollectedRecords_(
    commentsSheet,
    internalSheet,
    commentRows,
    internalRows
  );

  batchIds.forEach(function(id) {
    existingIds.add(id);
  });

  state.replyCount += replyAdded;

  const nextPageToken = data.nextPageToken || &#39;&#39;;
  const nextStatus = nextPageToken ? &#39;IN_PROGRESS&#39; : &#39;DONE&#39;;

  updateTopReplyProgress_(
    internalSheet,
    topRecord.rowNumber,
    nextStatus,
    nextReplyIndex,
    nextPageToken
  );

  if (nextPageToken) {
    state.nextReplyPageToken = nextPageToken;
  } else {
    state.currentReplyParentId = &#39;&#39;;
    state.nextReplyPageToken = &#39;&#39;;
  }

  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(&#39;IN_PROGRESS&#39;)
    .matchEntireCell(true)
    .useRegularExpression(false)
    .findNext();

  if (!found) {
    found = statusRange
      .createTextFinder(&#39;PENDING&#39;)
      .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] === &#39;PENDING&#39;) {
    internalSheet
      .getRange(rowNumber, YT_CFG.INTERNAL_COL.REPLY_STATUS)
      .setValue(&#39;IN_PROGRESS&#39;);
  }

  return {
    rowNumber: rowNumber,
    commentId: String(values[YT_CFG.INTERNAL_COL.COMMENT_ID - 1] || &#39;&#39;),
    displayNumber: String(
      values[YT_CFG.INTERNAL_COL.DISPLAY_NUMBER - 1] || &#39;&#39;
    ),
    nextReplyIndex: Number(
      values[YT_CFG.INTERNAL_COL.NEXT_REPLY_INDEX - 1] || 1
    ),
    pageToken: String(
      values[YT_CFG.INTERNAL_COL.REPLY_PAGE_TOKEN - 1] || &#39;&#39;
    )
  };
}


/**
 * 댓글 1건을 사용자가 보는 8열 행으로 변환합니다.
 */
function commentToOutputRow_(state, comment, displayNumber) {
  const snippet = (comment &amp;&amp; comment.snippet) || {};
  const text = String(snippet.textDisplay || &#39;&#39;);

  return [
    String(displayNumber),
    safeCellText_(state.videoChannelTitle || &#39;&#39;),
    safeCellText_(state.videoTitle || &#39;&#39;),
    safeCellText_(snippet.authorDisplayName || &#39;&#39;),
    safeCellText_(text),
    Number(snippet.likeCount || 0),
    apiDateToSheetValue_(snippet.publishedAt),
    calculateCommentBytes_(text)
  ];
}


/**
 * 숨김 내부 시트의 행을 만듭니다.
 */
function buildInternalRow_(
  commentId,
  parentCommentId,
  displayNumber,
  commentType,
  replyStatus,
  nextReplyIndex,
  totalReplyCount,
  replyPageToken
) {
  return [
    safeCellText_(commentId || &#39;&#39;),
    safeCellText_(parentCommentId || &#39;&#39;),
    String(displayNumber || &#39;&#39;),
    String(commentType || &#39;&#39;),
    String(replyStatus || &#39;&#39;),
    nextReplyIndex === &#39;&#39; ? &#39;&#39; : Number(nextReplyIndex || 1),
    totalReplyCount === &#39;&#39; ? &#39;&#39; : Number(totalReplyCount || 0),
    safeCellText_(replyPageToken || &#39;&#39;)
  ];
}


/**
 * 댓글 행과 내부 관리 행을 각각 일괄 기록합니다.
 */
function appendCollectedRecords_(
  commentsSheet,
  internalSheet,
  commentRows,
  internalRows
) {
  if (commentRows.length !== internalRows.length) {
    throwStructuredError_(buildErrorInfo_(
      &#39;INTERNAL_ROW_MISMATCH&#39;,
      &#39;댓글 저장 구조를 확인해야 합니다.&#39;,
      &#39;댓글 행과 내부 관리 행의 개수가 일치하지 않습니다.&#39;,
      [&#39;수집 상태를 기록하는 내부 데이터가 일시적으로 어긋났습니다.&#39;],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;사이드바에서 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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 &lt;= 1) {
    return null;
  }

  const found = internalSheet
    .getRange(2, YT_CFG.INTERNAL_COL.COMMENT_ID, lastRow - 1, 1)
    .createTextFinder(String(commentId || &#39;&#39;))
    .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] || &#39;&#39;),
    displayNumber: String(
      values[YT_CFG.INTERNAL_COL.DISPLAY_NUMBER - 1] || &#39;&#39;
    ),
    replyStatus: String(
      values[YT_CFG.INTERNAL_COL.REPLY_STATUS - 1] || &#39;&#39;
    ),
    nextReplyIndex: Number(
      values[YT_CFG.INTERNAL_COL.NEXT_REPLY_INDEX - 1] || 1
    ),
    replyPageToken: String(
      values[YT_CFG.INTERNAL_COL.REPLY_PAGE_TOKEN - 1] || &#39;&#39;
    )
  };
}


/**
 * 최상위 댓글의 답글 수집 진행 정보를 갱신합니다.
 */
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 || &#39;&#39;
    ]]);
}


/**
 * 완료 처리
 */
function finishCollection_(ss, commentsSheet, state) {
  state.status = &#39;COMPLETED&#39;;
  state.updatedAt = new Date().toISOString();
  state.finishedAt = new Date().toISOString();
  state.message = state.topCount &lt; state.targetTopLevelCount
    ? &#39;댓글 &#39; +
      state.topCount +
      &#39;개와 대댓글 &#39; +
      state.replyCount +
      &#39;개를 수집했습니다.&#39;
    : &#39;댓글 수집을 완료했습니다.&#39;;
  state.error = null;

  finalizeCommentsSheet_(commentsSheet);
  saveLastResult_(state, &#39;COMPLETED&#39;, 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) === &#39;true&#39;;
}


/**
 * 취소 처리
 */
function finalizeCancellation_(ss, commentsSheet, state) {
  const message = &#39;사용자가 수집을 취소했습니다. 이미 저장된 댓글은 유지됩니다.&#39;;

  state.status = &#39;CANCELLED&#39;;
  state.updatedAt = new Date().toISOString();
  state.finishedAt = new Date().toISOString();
  state.message = message;
  state.error = null;

  finalizeCommentsSheet_(commentsSheet);
  saveLastResult_(state, &#39;CANCELLED&#39;, 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_(
    &#39;videos&#39;,
    {
      part: &#39;id&#39;,
      id: &#39;jNQXAC9IVRw&#39;
    },
    apiKey
  );
}


/**
 * 영상 메타데이터를 조회합니다.
 */
function getVideoMetadataWithKey_(videoId, apiKey) {
  const data = youtubeGetWithKey_(
    &#39;videos&#39;,
    {
      part: &#39;snippet,statistics&#39;,
      id: videoId
    },
    apiKey
  );

  if (!Array.isArray(data.items) || data.items.length === 0) {
    throwStructuredError_(buildErrorInfo_(
      &#39;VIDEO_NOT_FOUND&#39;,
      &#39;영상 정보를 찾을 수 없습니다.&#39;,
      &#39;입력한 영상 주소에 해당하는 공개 영상을 YouTube Data API에서 조회하지 못했습니다.&#39;,
      [
        &#39;영상 주소나 11자리 영상 ID가 정확하지 않을 수 있습니다.&#39;,
        &#39;영상이 삭제되었거나 비공개 상태일 수 있습니다.&#39;,
        &#39;연령·지역·계정 권한에 따라 접근이 제한된 영상일 수 있습니다.&#39;
      ],
      [
        &#39;브라우저에서 해당 영상 주소가 정상적으로 열리는지 확인합니다.&#39;,
        &#39;주소 표시줄의 영상 URL 전체를 다시 복사합니다.&#39;,
        &#39;사이드바의 영상 주소를 교체한 뒤 수집을 다시 시작합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: { videoId: videoId }
      }
    ));
  }

  const item = data.items[0];
  const snippet = item.snippet || {};
  const statistics = item.statistics || {};

  return {
    title: snippet.title || &#39;&#39;,
    channelId: snippet.channelId || &#39;&#39;,
    channelTitle: snippet.channelTitle || &#39;&#39;,
    commentCount: statistics.commentCount || &#39;&#39;
  };
}


/**
 * 저장된 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 &amp;&amp; value !== null &amp;&amp; value !== &#39;&#39;;
    })
    .map(function(key) {
      return encodeURIComponent(key) + &#39;=&#39; + encodeURIComponent(queryParams[key]);
    })
    .join(&#39;&amp;&#39;);

  const url = YT_CFG.API_BASE + &#39;/&#39; + resource + &#39;?&#39; + query;
  const transientCodes = [429, 500, 502, 503, 504];

  for (let attempt = 0; attempt &lt; 3; attempt += 1) {
    let response;

    try {
      response = UrlFetchApp.fetch(url, {
        method: &#39;get&#39;,
        muteHttpExceptions: true,
        headers: { Accept: &#39;application/json&#39; }
      });
    } catch (networkError) {
      if (attempt &lt; 2) {
        Utilities.sleep(Math.pow(2, attempt) * 1000);
        continue;
      }

      throwStructuredError_(buildErrorInfo_(
        &#39;NETWORK_REQUEST_FAILED&#39;,
        &#39;YouTube 서버에 연결하지 못했습니다.&#39;,
        &#39;Apps Script에서 YouTube Data API로 보내는 외부 요청이 완료되지 않았습니다.&#39;,
        [
          &#39;Google 서비스 또는 네트워크에 일시적인 장애가 발생했을 수 있습니다.&#39;,
          &#39;Apps Script의 외부 요청 권한이 승인되지 않았을 수 있습니다.&#39;
        ],
        [
          &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
          &#39;Apps Script 권한 승인 화면이 나타나면 요청된 권한을 허용합니다.&#39;,
          &#39;Apps Script의 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;
        ],
        {
          actionLabel: &#39;확인&#39;,
          technical: {
            resource: resource,
            originalMessage: String(networkError &amp;&amp; networkError.message || networkError)
          }
        }
      ));
    }

    const statusCode = response.getResponseCode();
    const responseText = response.getContentText();

    if (statusCode &gt;= 200 &amp;&amp; statusCode &lt; 300) {
      return responseText ? JSON.parse(responseText) : {};
    }

    if (transientCodes.indexOf(statusCode) &gt;= 0 &amp;&amp; attempt &lt; 2) {
      Utilities.sleep(Math.pow(2, attempt) * 1000);
      continue;
    }

    const parsedError = parseYouTubeErrorResponse_(responseText);

    throwStructuredError_(classifyYouTubeApiError_(
      statusCode,
      parsedError.reason,
      parsedError.message,
      resource
    ));
  }

  throwStructuredError_(buildErrorInfo_(
    &#39;YOUTUBE_REQUEST_REPEATED_FAILURE&#39;,
    &#39;YouTube API 요청이 반복적으로 실패했습니다.&#39;,
    &#39;일시적인 서버 장애가 재시도 후에도 계속되었습니다.&#39;,
    [&#39;YouTube 또는 Google 네트워크 서비스가 불안정할 수 있습니다.&#39;],
    [
      &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
      &#39;반복되면 Apps Script의 ‘실행’ 기록을 확인합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: { resource: resource } }
  ));
}


function parseYouTubeErrorResponse_(responseText) {
  let reason = &#39;&#39;;
  let message = String(responseText || &#39;&#39;);

  try {
    const parsed = JSON.parse(responseText);

    if (parsed.error) {
      message = parsed.error.message || message;

      if (Array.isArray(parsed.error.errors) &amp;&amp; parsed.error.errors[0]) {
        reason = parsed.error.errors[0].reason || &#39;&#39;;
      }

      if (!reason &amp;&amp; parsed.error.status) {
        reason = parsed.error.status;
      }
    }
  } catch (parseError) {
    // JSON이 아닌 오류 본문은 문자열 그대로 분류합니다.
  }

  return {
    reason: String(reason || &#39;&#39;),
    message: String(message || &#39;&#39;)
  };
}


/**
 * YouTube API 오류를 한국어 모달용 정보로 분류합니다.
 */
function classifyYouTubeApiError_(statusCode, reason, message, resource) {
  const reasonText = String(reason || &#39;&#39;);
  const messageText = String(message || &#39;&#39;);
  const combined = (reasonText + &#39; &#39; + messageText).toLowerCase();
  const technical = {
    httpStatus: statusCode,
    apiReason: reasonText,
    resource: resource,
    originalMessage: messageText
  };

  if (
    combined.indexOf(&#39;keyinvalid&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api key not valid&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api_key_invalid&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_INVALID&#39;,
      &#39;API 키를 사용할 수 없습니다.&#39;,
      &#39;입력한 API 키가 정확하지 않거나 삭제·재발급되어 현재 요청에 사용할 수 없습니다.&#39;,
      [
        &#39;API 키 일부가 누락되었을 수 있습니다.&#39;,
        &#39;Google Cloud에서 기존 키가 삭제되거나 재발급되었을 수 있습니다.&#39;,
        &#39;API 키가 아닌 다른 프로젝트 값을 입력했을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;키 유형이 ‘API 키’인지 확인합니다.&#39;,
        &#39;API 키 전체를 다시 복사합니다.&#39;,
        &#39;사이드바에서 API 키를 다시 입력하고 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 관리 화면 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;accessnotconfigured&#39;) &gt;= 0 ||
    combined.indexOf(&#39;access not configured&#39;) &gt;= 0 ||
    combined.indexOf(&#39;service_disabled&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api_disabled&#39;) &gt;= 0 ||
    combined.indexOf(&#39;has not been used in project&#39;) &gt;= 0 ||
    combined.indexOf(&#39;it is disabled&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_API_NOT_ENABLED&#39;,
      &#39;YouTube Data API v3가 활성화되어 있지 않습니다.&#39;,
      &#39;API 키가 속한 Google Cloud 프로젝트에서 YouTube Data API v3 사용 설정을 켜야 합니다.&#39;,
      [
        &#39;API 키를 만든 프로젝트에서 YouTube Data API v3를 아직 활성화하지 않았습니다.&#39;,
        &#39;API를 활성화한 프로젝트와 API 키를 발급한 프로젝트가 다를 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console에서 API 키를 만든 프로젝트를 선택합니다.&#39;,
        &#39;‘API 및 서비스 → 라이브러리’로 이동합니다.&#39;,
        &#39;‘YouTube Data API v3’를 검색하고 ‘사용’을 누릅니다.&#39;,
        &#39;설정 반영을 위해 1~5분 기다린 뒤 API 키를 다시 저장합니다.&#39;
      ],
      {
        helpLabel: &#39;YouTube Data API 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/library/youtube.googleapis.com&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;iprefererblocked&#39;) &gt;= 0 ||
    combined.indexOf(&#39;referer&#39;) &gt;= 0 &amp;&amp; combined.indexOf(&#39;blocked&#39;) &gt;= 0 ||
    combined.indexOf(&#39;api key restrictions&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_APPLICATION_RESTRICTION&#39;,
      &#39;API 키의 애플리케이션 제한이 Apps Script 요청을 차단했습니다.&#39;,
      &#39;현재 키에 설정된 웹사이트·IP 제한이 Apps Script의 UrlFetch 요청 방식과 맞지 않습니다.&#39;,
      [
        &#39;HTTP 리퍼러 제한 또는 특정 IP 주소 제한이 적용되어 있을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;사용 중인 API 키의 애플리케이션 제한을 확인합니다.&#39;,
        &#39;Apps Script에서 사용할 수 있는 제한 방식으로 조정합니다.&#39;,
        &#39;API 제한은 ‘YouTube Data API v3’로 유지합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 제한 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;accessnotconfigured&#39;) &lt; 0 &amp;&amp;
    (
      combined.indexOf(&#39;forbidden&#39;) &gt;= 0 &amp;&amp;
      combined.indexOf(&#39;api&#39;) &gt;= 0 &amp;&amp;
      combined.indexOf(&#39;key&#39;) &gt;= 0
    )
  ) {
    return buildErrorInfo_(
      &#39;API_KEY_API_RESTRICTION&#39;,
      &#39;API 키의 API 제한 설정을 확인해야 합니다.&#39;,
      &#39;현재 API 키가 YouTube Data API v3 호출을 허용하지 않을 수 있습니다.&#39;,
      [
        &#39;API 키의 ‘API 제한’ 목록에서 YouTube Data API v3가 빠져 있을 수 있습니다.&#39;
      ],
      [
        &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
        &#39;사용 중인 API 키를 선택합니다.&#39;,
        &#39;‘API 제한’에서 YouTube Data API v3를 허용합니다.&#39;,
        &#39;저장 후 1~5분 뒤 다시 실행합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 제한 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;quotaexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;dailylimitexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;daily limit&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_QUOTA_EXCEEDED&#39;,
      &#39;YouTube API의 오늘 할당량을 모두 사용했습니다.&#39;,
      &#39;현재 Google Cloud 프로젝트에서 오늘 사용할 수 있는 YouTube Data API 할당량이 남아 있지 않습니다.&#39;,
      [&#39;같은 프로젝트의 다른 프로그램에서도 YouTube API 할당량을 사용했을 수 있습니다.&#39;],
      [
        &#39;Google Cloud Console에서 YouTube Data API 사용량을 확인합니다.&#39;,
        &#39;일일 할당량이 갱신된 뒤 다시 실행합니다.&#39;,
        &#39;필요하면 다음 수집의 댓글 개수를 줄입니다.&#39;
      ],
      {
        helpLabel: &#39;YouTube API 할당량 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas&#39;,
        actionLabel: &#39;확인&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;ratelimitexceeded&#39;) &gt;= 0 ||
    combined.indexOf(&#39;userratelimitexceeded&#39;) &gt;= 0 ||
    statusCode === 429
  ) {
    return buildErrorInfo_(
      &#39;YOUTUBE_RATE_LIMIT&#39;,
      &#39;짧은 시간에 API 요청이 많이 발생했습니다.&#39;,
      &#39;YouTube Data API가 일시적으로 요청 속도를 제한했습니다.&#39;,
      [&#39;여러 수집 작업을 연속으로 실행했을 수 있습니다.&#39;],
      [
        &#39;현재 작업이 자동 재개되는지 확인합니다.&#39;,
        &#39;몇 분 뒤 다시 실행합니다.&#39;,
        &#39;동시에 여러 스프레드시트에서 같은 API 키를 사용하고 있다면 실행 횟수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (combined.indexOf(&#39;commentsdisabled&#39;) &gt;= 0) {
    return buildErrorInfo_(
      &#39;COMMENTS_DISABLED&#39;,
      &#39;이 영상에서는 댓글을 수집할 수 없습니다.&#39;,
      &#39;영상 게시자가 댓글 기능을 사용하지 않도록 설정했습니다.&#39;,
      [&#39;영상의 댓글 기능이 꺼져 있거나 YouTube 정책에 따라 제한되었습니다.&#39;],
      [
        &#39;영상 페이지에서 댓글 영역이 표시되는지 확인합니다.&#39;,
        &#39;댓글이 허용된 다른 영상 주소를 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (
    combined.indexOf(&#39;videonotfound&#39;) &gt;= 0 ||
    combined.indexOf(&#39;video not found&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;VIDEO_NOT_FOUND&#39;,
      &#39;영상 정보를 찾을 수 없습니다.&#39;,
      &#39;영상 주소가 잘못되었거나 영상이 삭제·비공개 상태일 수 있습니다.&#39;,
      [
        &#39;영상 ID가 정확하지 않을 수 있습니다.&#39;,
        &#39;영상 공개 상태가 변경되었을 수 있습니다.&#39;
      ],
      [
        &#39;브라우저에서 영상 주소가 정상적으로 열리는지 확인합니다.&#39;,
        &#39;영상 URL 전체를 다시 복사해 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 다시 입력&#39;,
        technical: technical
      }
    );
  }

  if (statusCode &gt;= 500) {
    return buildErrorInfo_(
      &#39;YOUTUBE_SERVER_ERROR&#39;,
      &#39;YouTube 서버에서 일시적인 오류가 발생했습니다.&#39;,
      &#39;YouTube Data API 서버가 현재 요청을 정상적으로 처리하지 못했습니다.&#39;,
      [&#39;YouTube 측의 일시적인 서비스 장애일 수 있습니다.&#39;],
      [
        &#39;잠시 뒤 같은 작업을 다시 실행합니다.&#39;,
        &#39;진행 중인 수집이라면 자동 재개 여부를 확인합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (statusCode === 403) {
    return buildErrorInfo_(
      &#39;YOUTUBE_ACCESS_FORBIDDEN&#39;,
      &#39;YouTube 데이터에 접근할 수 없습니다.&#39;,
      &#39;API 키 설정, 영상 공개 상태 또는 Google Cloud 프로젝트 권한을 확인해야 합니다.&#39;,
      [
        &#39;API 키 제한 설정이 요청을 차단했을 수 있습니다.&#39;,
        &#39;영상이 공개 API에서 접근할 수 없는 상태일 수 있습니다.&#39;
      ],
      [
        &#39;API 키의 API 제한과 애플리케이션 제한을 확인합니다.&#39;,
        &#39;영상이 공개 상태인지 확인합니다.&#39;,
        &#39;Google Cloud에서 YouTube Data API v3가 활성화되어 있는지 확인합니다.&#39;
      ],
      {
        helpLabel: &#39;API 키 설정 열기&#39;,
        helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
        actionLabel: &#39;설정 확인&#39;,
        technical: technical
      }
    );
  }

  if (statusCode === 400) {
    return buildErrorInfo_(
      &#39;YOUTUBE_BAD_REQUEST&#39;,
      &#39;YouTube API 요청 형식을 확인해야 합니다.&#39;,
      &#39;영상 주소 또는 요청 매개변수 중 하나를 YouTube API가 처리하지 못했습니다.&#39;,
      [&#39;영상 주소가 올바르지 않거나 API 요청 값이 허용 범위를 벗어났을 수 있습니다.&#39;],
      [
        &#39;영상 주소를 다시 확인합니다.&#39;,
        &#39;수집 개수와 정렬 기준을 확인합니다.&#39;,
        &#39;같은 문제가 계속되면 새 수집으로 다시 시작합니다.&#39;
      ],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;입력값 확인&#39;,
        technical: technical
      }
    );
  }

  return buildErrorInfo_(
    &#39;YOUTUBE_API_ERROR&#39;,
    &#39;YouTube API 요청을 완료하지 못했습니다.&#39;,
    &#39;YouTube Data API가 요청을 거부하거나 처리하지 못했습니다.&#39;,
    [&#39;API 키, 영상 공개 상태, 할당량 또는 네트워크 상태를 확인해야 합니다.&#39;],
    [
      &#39;API 키 설정과 YouTube Data API 활성화 여부를 확인합니다.&#39;,
      &#39;영상 주소가 공개 영상인지 확인합니다.&#39;,
      &#39;잠시 뒤 다시 실행합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: technical }
  );
}


/**
 * 영상 URL 또는 11자리 영상 ID에서 videoId를 추출합니다.
 */
function extractVideoId_(input) {
  const text = String(input || &#39;&#39;).trim();

  if (/^[A-Za-z0-9_-]{11}$/.test(text)) {
    return text;
  }

  const patterns = [
    /[?&amp;]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 &lt; patterns.length; i += 1) {
    const match = text.match(patterns[i]);

    if (match) {
      return match[1];
    }
  }

  throwStructuredError_(buildErrorInfo_(
    &#39;INVALID_VIDEO_URL&#39;,
    &#39;YouTube 영상 주소를 확인해야 합니다.&#39;,
    &#39;입력한 값에서 11자리 YouTube 영상 ID를 찾지 못했습니다.&#39;,
    [
      &#39;영상 주소 일부가 빠졌을 수 있습니다.&#39;,
      &#39;채널·재생목록 주소를 입력했을 수 있습니다.&#39;
    ],
    [
      &#39;YouTube에서 수집할 영상을 엽니다.&#39;,
      &#39;브라우저 주소 표시줄의 영상 URL 전체를 복사합니다.&#39;,
      &#39;사이드바의 영상 주소 입력란에 붙여 넣습니다.&#39;
    ],
    {
      focusTarget: &#39;videoUrl&#39;,
      actionLabel: &#39;영상 주소 다시 입력&#39;
    }
  ));
}


/**
 * 사이드바 입력값을 검증합니다.
 */
function normalizeSidebarInput_(formData) {
  const form = formData || {};
  const videoUrl = String(form.videoUrl || &#39;&#39;).trim();
  const limit = parseInt(form.limit, 10);

  if (!videoUrl) {
    throwStructuredError_(buildErrorInfo_(
      &#39;VIDEO_URL_REQUIRED&#39;,
      &#39;YouTube 영상 주소를 입력해야 합니다.&#39;,
      &#39;수집 대상 영상 주소가 비어 있습니다.&#39;,
      [&#39;영상 주소 입력란에 값이 없습니다.&#39;],
      [&#39;수집할 YouTube 영상의 URL을 입력합니다.&#39;],
      {
        focusTarget: &#39;videoUrl&#39;,
        actionLabel: &#39;영상 주소 입력&#39;
      }
    ));
  }

  if (!Number.isInteger(limit)) {
    throwStructuredError_(buildErrorInfo_(
      &#39;COMMENT_LIMIT_INVALID&#39;,
      &#39;수집 개수를 정수로 입력해야 합니다.&#39;,
      &#39;수집할 최상위 댓글 개수가 올바른 정수 형식이 아닙니다.&#39;,
      [&#39;빈 값, 소수 또는 문자가 입력되었을 수 있습니다.&#39;],
      [&#39;1 이상의 정수를 입력합니다.&#39;],
      {
        focusTarget: &#39;limit&#39;,
        actionLabel: &#39;수집 개수 입력&#39;
      }
    ));
  }

  if (limit &lt; YT_CFG.MIN_LIMIT || limit &gt; YT_CFG.MAX_LIMIT) {
    throwStructuredError_(buildErrorInfo_(
      &#39;COMMENT_LIMIT_OUT_OF_RANGE&#39;,
      &#39;수집 개수가 허용 범위를 벗어났습니다.&#39;,
      &#39;수집 개수는 &#39; + YT_CFG.MIN_LIMIT + &#39;개 이상 &#39; +
        YT_CFG.MAX_LIMIT + &#39;개 이하로 입력해야 합니다.&#39;,
      [&#39;입력한 개수가 코드에 설정된 운영 범위를 벗어났습니다.&#39;],
      [
        YT_CFG.MIN_LIMIT + &#39;~&#39; + YT_CFG.MAX_LIMIT +
          &#39; 사이의 정수를 입력합니다.&#39;
      ],
      {
        focusTarget: &#39;limit&#39;,
        actionLabel: &#39;수집 개수 수정&#39;
      }
    ));
  }

  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 ... 순서로 만듭니다.
 *
 * 새 스프레드시트에 기본 빈 &#39;시트1&#39; 하나만 존재하는 경우에는 그 시트를
 * 첫 결과 시트로 사용합니다. 그 이후 수집부터는 항상 새 시트를 추가합니다.
 */
function createNextOutputSheet_(ss) {
  const sheets = ss.getSheets();

  if (
    sheets.length === 1 &amp;&amp;
    sheets[0].getName() === YT_CFG.OUTPUT_SHEET_PREFIX + &#39;1&#39; &amp;&amp;
    isSheetCompletelyBlank_(sheets[0])
  ) {
    prepareOutputSheet_(sheets[0]);
    return sheets[0];
  }

  let maxNumber = 0;
  const namePattern = new RegExp(
    &#39;^&#39; + escapeRegExp_(YT_CFG.OUTPUT_SHEET_PREFIX) + &#39;(\\d+)$&#39;
  );

  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 &amp;&amp; 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(&#39;A:A&#39;).setNumberFormat(&#39;@&#39;);
  formatCommentsSheet_(sheet);
}


/**
 * 새 수집을 위해 숨김 내부 시트만 초기화합니다.
 * 이전 결과 시트의 내용은 변경하지 않습니다.
 */
function resetInternalData_(ss) {
  const internal = ss.getSheetByName(YT_CFG.INTERNAL_SHEET);

  if (!internal) {
    throwStructuredError_(buildErrorInfo_(
      &#39;INTERNAL_SHEET_NOT_FOUND&#39;,
      &#39;내부 관리 시트를 준비하지 못했습니다.&#39;,
      &#39;댓글 수집 상태를 저장하는 숨김 시트가 존재하지 않습니다.&#39;,
      [&#39;내부 시트 생성 과정이 완료되지 않았습니다.&#39;],
      [&#39;사이드바를 닫았다가 다시 열고 새 수집을 시작합니다.&#39;],
      { actionLabel: &#39;확인&#39; }
    ));
  }

  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 &gt; 0) {
    outputSheet = ss.getSheets().find(function(sheet) {
      return sheet.getSheetId() === expectedId;
    }) || null;
  }

  if (!outputSheet &amp;&amp; state.outputSheetName) {
    outputSheet = ss.getSheetByName(String(state.outputSheetName));
  }

  if (!outputSheet) {
    throwStructuredError_(buildErrorInfo_(
      &#39;OUTPUT_SHEET_NOT_FOUND&#39;,
      &#39;수집 결과 시트를 찾을 수 없습니다.&#39;,
      &#39;현재 수집 작업과 연결된 결과 시트가 삭제되었거나 이름과 식별 정보가 변경되었습니다.&#39;,
      [
        &#39;수집 중 결과 시트를 삭제했을 수 있습니다.&#39;,
        &#39;스프레드시트 구조가 외부 작업으로 변경되었을 수 있습니다.&#39;
      ],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;사이드바에서 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;새 수집 필요&#39; }
    ));
  }

  return outputSheet;
}


function escapeRegExp_(text) {
  return String(text).replace(/[.*+?^${}()|[\]\\]/g, &#39;\\$&amp;&#39;);
}


/**
 * 이전 버전이 만든 수집로그 시트를 제거합니다.
 */
function deleteLegacyLogSheet_(ss) {
  const logSheet = ss.getSheetByName(YT_CFG.LEGACY_LOG_SHEET);

  if (logSheet &amp;&amp; ss.getSheets().length &gt; 1) {
    ss.deleteSheet(logSheet);
  }
}


/**
 * 댓글 시트를 번호 순으로 정렬하고 최종 서식을 적용합니다.
 */
function finalizeCommentsSheet_(sheet) {
  sortCommentsByDisplayNumber_(sheet);
  formatCommentsSheet_(sheet);
}


function sortCommentsByDisplayNumber_(sheet) {
  const lastRow = sheet.getLastRow();

  if (lastRow &lt;= 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] || &#39;&#39;).split(&#39;_&#39;);
    return [
      Number(parts[0] || 0),
      parts.length &gt; 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(&#39;bold&#39;)
    .setWrap(true)
    .setVerticalAlignment(&#39;middle&#39;);

  sheet.getRange(&#39;A:A&#39;).setNumberFormat(&#39;@&#39;);

  if (lastRow &gt; 1) {
    sheet
      .getRange(2, 7, lastRow - 1, 1)
      .setNumberFormat(&#39;yyyy-mm-dd hh:mm:ss&#39;);

    sheet
      .getRange(2, 5, lastRow - 1, 1)
      .setWrap(true)
      .setVerticalAlignment(&#39;top&#39;);

    sheet
      .getRange(2, 1, lastRow - 1, YT_CFG.HEADERS.length)
      .setVerticalAlignment(&#39;top&#39;);

    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 &lt;= 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 &lt;= 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] === &#39;topLevel&#39;) {
      counts.top += 1;
    } else if (row[0] === &#39;reply&#39;) {
      counts.reply += 1;
    }
  });

  return counts;
}


/**
 * 댓글 글자수를 지정된 바이트 규칙으로 계산합니다.
 * - ASCII 영역(영문, 숫자, 공백, 일반 문장부호): 1바이트
 * - 한글을 포함한 비ASCII 유니코드 문자: 코드 포인트당 2바이트
 */
function calculateCommentBytes_(value) {
  const text = String(value || &#39;&#39;);
  let bytes = 0;

  Array.from(text).forEach(function(character) {
    const codePoint = character.codePointAt(0);
    bytes += codePoint &lt;= 0x7F ? 1 : 2;
  });

  return bytes;
}


/**
 * 외부 문자열이 시트 수식으로 실행되는 것을 방지합니다.
 */
function safeCellText_(value) {
  const text = value === undefined || value === null ? &#39;&#39; : String(value);
  return /^[=+\-@]/.test(text) ? &quot;&#39;&quot; + text : text;
}


function apiDateToSheetValue_(value) {
  return value ? new Date(value) : &#39;&#39;;
}


function ensureColumnCount_(sheet, requiredColumns) {
  const current = sheet.getMaxColumns();

  if (current &lt; requiredColumns) {
    sheet.insertColumnsAfter(current, requiredColumns - current);
  }
}


function ensureExactColumnCount_(sheet, requiredColumns) {
  ensureColumnCount_(sheet, requiredColumns);

  const current = sheet.getMaxColumns();

  if (current &gt; 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_(
      &#39;API_KEY_REQUIRED&#39;,
      &#39;API 키 설정이 필요합니다.&#39;,
      &#39;댓글 수집을 시작하기 전에 YouTube Data API 키를 저장해야 합니다.&#39;,
      [&#39;현재 사용자 계정에 저장된 API 키가 없습니다.&#39;],
      [
        &#39;사이드바 상단의 API 키 입력란에 키를 입력합니다.&#39;,
        &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
      ],
      {
        focusTarget: &#39;apiKey&#39;,
        actionLabel: &#39;API 키 입력&#39;
      }
    ));
  }

  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 &gt; 8500) {
    throwStructuredError_(buildErrorInfo_(
      &#39;STATE_STORAGE_LIMIT&#39;,
      &#39;수집 진행 상태를 저장할 수 없습니다.&#39;,
      &#39;Apps Script 사용자 속성의 안전 저장 크기를 초과했습니다.&#39;,
      [&#39;진행 상태 데이터가 예상보다 커졌습니다.&#39;],
      [
        &#39;현재 수집을 취소합니다.&#39;,
        &#39;댓글 수집 개수를 줄여 새로 실행합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, 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 || &#39;&#39;,
    videoId: state.videoId || &#39;&#39;,
    videoTitle: state.videoTitle || &#39;&#39;,
    targetTopLevelCount: Number(state.targetTopLevelCount || 0),
    topCount: Number(state.topCount || 0),
    replyCount: Number(state.replyCount || 0),
    apiCalls: Number(state.apiCalls || 0),
    startedAt: state.startedAt || &#39;&#39;,
    updatedAt: new Date().toISOString(),
    finishedAt: new Date().toISOString(),
    message: message || &#39;&#39;,
    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_(
      &#39;STATE_VERSION_MISMATCH&#39;,
      &#39;이전 코드에서 시작된 수집 작업을 이어갈 수 없습니다.&#39;,
      &#39;현재 Code.gs의 댓글 저장 구조가 이전 실행 상태와 다릅니다.&#39;,
      [&#39;고정 결과 시트 또는 정렬 기준을 사용하던 이전 코드의 작업 상태가 남아 있습니다.&#39;],
      [
        &#39;사이드바에서 진행 중인 수집을 취소합니다.&#39;,
        &#39;현재 코드로 같은 영상을 새로 수집합니다.&#39;
      ],
      { actionLabel: &#39;새 수집 필요&#39; }
    ));
  }
}


function normalizeState_(state) {
  state.currentReplyParentId = state.currentReplyParentId || &#39;&#39;;
  state.nextReplyPageToken = state.nextReplyPageToken || &#39;&#39;;
  state.nextThreadPageToken = state.nextThreadPageToken || &#39;&#39;;
  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 || &#39;&#39;);
  state.error = state.error || null;
}


function maskApiKey_(apiKey) {
  const key = String(apiKey || &#39;&#39;);
  return &#39;••••••••••••&#39; + 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 &gt; 0
    ? Math.min(100, Math.round((top / target) * 100))
    : 0;

  let stage = &#39;대기 중&#39;;

  if (status === &#39;RUNNING&#39;) {
    if (state.currentReplyParentId) {
      stage = &#39;답글 수집 중&#39;;
    } else if (!state.threadDone) {
      stage = &#39;최상위 댓글 수집 중&#39;;
    } else {
      stage = &#39;마무리 중&#39;;
    }
  } else if (status === &#39;COMPLETED&#39;) {
    stage = &#39;완료&#39;;
  } else if (status === &#39;ERROR&#39;) {
    stage = &#39;오류&#39;;
  } else if (status === &#39;CANCELLED&#39;) {
    stage = &#39;취소됨&#39;;
  }

  return {
    status: status,
    stage: stage,
    outputSheetId: Number(state.outputSheetId || 0),
    outputSheetName: state.outputSheetName || &#39;&#39;,
    videoId: state.videoId || &#39;&#39;,
    videoTitle: state.videoTitle || &#39;&#39;,
    targetTopLevelCount: target,
    topCount: top,
    replyCount: reply,
    totalRows: top + reply,
    apiCalls: Number(state.apiCalls || 0),
    percent: percent,
    startedAt: state.startedAt || &#39;&#39;,
    updatedAt: state.updatedAt || &#39;&#39;,
    message: state.message || &#39;&#39;,
    error: state.error || null
  };
}


function buildServerProgressMessage_(state) {
  return (
    &#39;최상위 댓글 &#39; +
    state.topCount +
    &#39;/&#39; +
    state.targetTopLevelCount +
    &#39;개, 답글 &#39; +
    state.replyCount +
    &#39;개를 저장했습니다.&#39;
  );
}


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 || &#39;UNKNOWN_ERROR&#39;),
    title: String(title || &#39;작업을 완료하지 못했습니다.&#39;),
    summary: String(summary || &#39;처리 중 문제가 발생했습니다.&#39;),
    causes: Array.isArray(causes) ? causes.map(String) : [],
    steps: Array.isArray(steps) ? steps.map(String) : [],
    helpLabel: String(opts.helpLabel || &#39;&#39;),
    helpUrl: String(opts.helpUrl || &#39;&#39;),
    focusTarget: String(opts.focusTarget || &#39;&#39;),
    actionLabel: String(opts.actionLabel || &#39;확인&#39;),
    technical:
      opts.technical &amp;&amp; typeof opts.technical === &#39;object&#39;
        ? 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 || &#39;&#39;);
  const markerIndex = raw.indexOf(YT_CFG.STRUCTURED_ERROR_PREFIX);

  if (markerIndex &lt; 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 &amp;&amp; error.message
    ? String(error.message)
    : String(error || &#39;&#39;);

  const structured = extractStructuredError_(raw);

  if (structured) {
    return structured;
  }

  const lower = raw.toLowerCase();
  const technical = {
    context: String(context || &#39;작업 실행&#39;),
    originalMessage: raw
  };

  if (
    lower.indexOf(&#39;authorization is required&#39;) &gt;= 0 ||
    lower.indexOf(&#39;you do not have permission&#39;) &gt;= 0 ||
    lower.indexOf(&#39;permissions&#39;) &gt;= 0 &amp;&amp; lower.indexOf(&#39;required&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;,
      &#39;Google 권한 승인이 필요합니다.&#39;,
      &#39;현재 계정이 스프레드시트 또는 외부 API 요청 권한을 아직 승인하지 않았습니다.&#39;,
      [&#39;Apps Script를 처음 실행했거나 새로운 권한이 추가되었습니다.&#39;],
      [
        &#39;스프레드시트 메뉴에서 사이드바를 다시 엽니다.&#39;,
        &#39;권한 검토 화면에서 현재 Google 계정을 선택합니다.&#39;,
        &#39;요청되는 권한을 허용한 뒤 다시 실행합니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (
    lower.indexOf(&#39;maximum execution time&#39;) &gt;= 0 ||
    lower.indexOf(&#39;exceeded maximum execution time&#39;) &gt;= 0 ||
    lower.indexOf(&#39;timed out&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;,
      &#39;한 번의 실행 가능 시간을 초과했습니다.&#39;,
      &#39;댓글 또는 답글 처리 시간이 길어 현재 실행 구간이 종료되었습니다.&#39;,
      [&#39;대량 댓글 수집으로 한 번의 Apps Script 실행 시간이 길어졌습니다.&#39;],
      [
        &#39;사이드바를 다시 열어 진행 상태를 확인합니다.&#39;,
        &#39;멈춘 경우 ‘중단 지점부터 실행’을 한 번 실행합니다.&#39;,
        &#39;같은 문제가 반복되면 수집 개수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  if (
    lower.indexOf(&#39;service invoked too many times&#39;) &gt;= 0 ||
    lower.indexOf(&#39;quota&#39;) &gt;= 0 &amp;&amp; lower.indexOf(&#39;apps script&#39;) &gt;= 0
  ) {
    return buildErrorInfo_(
      &#39;APPS_SCRIPT_SERVICE_QUOTA&#39;,
      &#39;Apps Script 서비스 사용 한도를 초과했습니다.&#39;,
      &#39;현재 Google 계정 또는 스크립트의 일일 서비스 사용량이 허용 범위를 넘었습니다.&#39;,
      [&#39;짧은 시간에 많은 외부 요청이나 스프레드시트 쓰기 작업이 실행되었습니다.&#39;],
      [
        &#39;잠시 뒤 다시 실행합니다.&#39;,
        &#39;같은 날 반복되면 다음 날 다시 실행합니다.&#39;,
        &#39;한 번에 수집하는 댓글 개수를 줄입니다.&#39;
      ],
      { actionLabel: &#39;확인&#39;, technical: technical }
    );
  }

  return buildErrorInfo_(
    &#39;UNEXPECTED_ERROR&#39;,
    &#39;예상하지 못한 오류가 발생했습니다.&#39;,
    &#39;오류 유형을 자동으로 판별하지 못했습니다.&#39;,
    [
      &#39;네트워크, 권한, Google 서비스 상태 또는 실행 환경에서 문제가 발생했을 수 있습니다.&#39;
    ],
    [
      &#39;사이드바를 닫았다가 다시 열고 같은 작업을 한 번 더 실행합니다.&#39;,
      &#39;Apps Script 편집기의 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;,
      &#39;API 키와 영상 주소를 다시 확인합니다.&#39;
    ],
    { actionLabel: &#39;확인&#39;, technical: technical }
  );
}
</pre></section><section><h3>Sidebar.html</h3><pre>&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;meta charset=&quot;utf-8&quot;&gt;
    &lt;style&gt;
      :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, &#39;Noto Sans KR&#39;, 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=&#39;text&#39;],
      input[type=&#39;password&#39;],
      input[type=&#39;number&#39;] {
        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);
        }
      }
    &lt;/style&gt;
  &lt;/head&gt;

  &lt;body&gt;
    &lt;h1&gt;YouTube 댓글 수집기&lt;/h1&gt;
    &lt;p class=&quot;subtitle&quot;&gt;YouTube Data API v3 → Google Sheets&lt;/p&gt;

    &lt;div id=&quot;message&quot; class=&quot;message&quot;&gt;&lt;/div&gt;

    &lt;section class=&quot;card&quot;&gt;
      &lt;h2&gt;1. API 키&lt;/h2&gt;

      &lt;div id=&quot;apiMissingView&quot;&gt;
        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;apiKey&quot;&gt;YouTube Data API 키&lt;/label&gt;
          &lt;input
            id=&quot;apiKey&quot;
            type=&quot;password&quot;
            autocomplete=&quot;off&quot;
            placeholder=&quot;AIza...&quot;
          &gt;
          &lt;div class=&quot;help&quot;&gt;
            최초 1회 저장합니다. 키는 사용자별 Apps Script 속성에 보관됩니다.
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;button id=&quot;saveApiButton&quot; class=&quot;primary&quot; type=&quot;button&quot;&gt;
          API 키 확인 후 저장
        &lt;/button&gt;
      &lt;/div&gt;

      &lt;div id=&quot;apiSavedView&quot; class=&quot;hidden&quot;&gt;
        &lt;div class=&quot;row between&quot;&gt;
          &lt;div class=&quot;status-line&quot;&gt;
            &lt;span class=&quot;badge success&quot;&gt;설정 완료&lt;/span&gt;
            &lt;span id=&quot;apiKeyMask&quot; class=&quot;masked-key&quot;&gt;&lt;/span&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;div class=&quot;row&quot; style=&quot;margin-top: 10px;&quot;&gt;
          &lt;button id=&quot;changeApiButton&quot; class=&quot;secondary small-button&quot; type=&quot;button&quot;&gt;
            키 변경
          &lt;/button&gt;
          &lt;button id=&quot;deleteApiButton&quot; class=&quot;danger small-button&quot; type=&quot;button&quot;&gt;
            키 삭제
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/section&gt;

    &lt;section class=&quot;card soft&quot;&gt;
      &lt;h2&gt;2. 수집 설정&lt;/h2&gt;

      &lt;div id=&quot;lockedMessage&quot; class=&quot;locked-message&quot;&gt;
        API 키를 저장하면 수집 설정이 활성화됩니다.
      &lt;/div&gt;

      &lt;fieldset id=&quot;collectorFieldset&quot; disabled&gt;
        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;videoUrl&quot;&gt;YouTube 영상 주소&lt;/label&gt;
          &lt;input
            id=&quot;videoUrl&quot;
            type=&quot;text&quot;
            placeholder=&quot;https://www.youtube.com/watch?v=...&quot;
          &gt;
        &lt;/div&gt;

        &lt;div class=&quot;field&quot;&gt;
          &lt;label for=&quot;limit&quot;&gt;수집할 최상위 댓글 수&lt;/label&gt;
          &lt;input
            id=&quot;limit&quot;
            type=&quot;number&quot;
            min=&quot;1&quot;
            max=&quot;50000&quot;
            step=&quot;1&quot;
            value=&quot;100&quot;
          &gt;
          &lt;div class=&quot;help&quot;&gt;
            1~50,000개. 답글 포함 시 실제 저장 행 수가 이 값보다 많아질 수 있습니다.
          &lt;/div&gt;
        &lt;/div&gt;


        &lt;div class=&quot;field check-row&quot;&gt;
          &lt;input id=&quot;includeReplies&quot; type=&quot;checkbox&quot; checked&gt;
          &lt;label for=&quot;includeReplies&quot;&gt;
            대댓글도 모두 수집
          &lt;/label&gt;
        &lt;/div&gt;

        &lt;div class=&quot;help&quot; style=&quot;margin-bottom: 10px;&quot;&gt;
          새 수집을 시작할 때마다 ‘시트1’, ‘시트2’, ‘시트3’ 형식의 새 시트가 생성됩니다. 기존 수집 결과는 유지됩니다.
        &lt;/div&gt;

        &lt;button id=&quot;startButton&quot; class=&quot;primary&quot; type=&quot;button&quot;&gt;
          수집 시작
        &lt;/button&gt;
      &lt;/fieldset&gt;
    &lt;/section&gt;

    &lt;section id=&quot;progressCard&quot; class=&quot;card hidden&quot;&gt;
      &lt;div class=&quot;row between&quot;&gt;
        &lt;h2 style=&quot;margin: 0;&quot;&gt;진행 상태&lt;/h2&gt;
        &lt;span id=&quot;progressBadge&quot; class=&quot;badge warning&quot;&gt;대기 중&lt;/span&gt;
      &lt;/div&gt;

      &lt;div id=&quot;progressTitle&quot; class=&quot;progress-title&quot; style=&quot;margin-top: 10px;&quot;&gt;&lt;/div&gt;

      &lt;div class=&quot;progress-bar&quot;&gt;
        &lt;div id=&quot;progressFill&quot; class=&quot;progress-fill&quot;&gt;&lt;/div&gt;
      &lt;/div&gt;

      &lt;div class=&quot;metric-grid&quot;&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;댓글&lt;/div&gt;
          &lt;div id=&quot;topMetric&quot; class=&quot;metric-value&quot;&gt;0 / 0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;대댓글&lt;/div&gt;
          &lt;div id=&quot;replyMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;전체 저장 행&lt;/div&gt;
          &lt;div id=&quot;totalMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;metric&quot;&gt;
          &lt;div class=&quot;metric-label&quot;&gt;API 호출 횟수&lt;/div&gt;
          &lt;div id=&quot;apiCallMetric&quot; class=&quot;metric-value&quot;&gt;0&lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;p id=&quot;progressMessage&quot; class=&quot;help&quot;&gt;&lt;/p&gt;

      &lt;button id=&quot;cancelButton&quot; class=&quot;danger hidden&quot; type=&quot;button&quot; style=&quot;width: 100%; margin-top: 12px;&quot;&gt;
        진행 중인 수집 취소
      &lt;/button&gt;
    &lt;/section&gt;


    &lt;div
      id=&quot;errorModalBackdrop&quot;
      class=&quot;modal-backdrop hidden&quot;
      role=&quot;dialog&quot;
      aria-modal=&quot;true&quot;
      aria-labelledby=&quot;errorModalTitle&quot;
    &gt;
      &lt;div class=&quot;modal-panel&quot;&gt;
        &lt;div class=&quot;modal-header&quot;&gt;
          &lt;div class=&quot;modal-icon&quot; aria-hidden=&quot;true&quot;&gt;!&lt;/div&gt;

          &lt;div class=&quot;modal-heading&quot;&gt;
            &lt;h2 id=&quot;errorModalTitle&quot; class=&quot;modal-title&quot;&gt;
              문제를 확인해야 합니다.
            &lt;/h2&gt;
            &lt;div id=&quot;errorModalCode&quot; class=&quot;modal-code&quot;&gt;&lt;/div&gt;
          &lt;/div&gt;

          &lt;button
            id=&quot;errorModalCloseButton&quot;
            class=&quot;modal-close&quot;
            type=&quot;button&quot;
            aria-label=&quot;오류 안내 닫기&quot;
          &gt;
            ×
          &lt;/button&gt;
        &lt;/div&gt;

        &lt;div class=&quot;modal-body&quot;&gt;
          &lt;div id=&quot;errorModalSummary&quot; class=&quot;modal-summary&quot;&gt;&lt;/div&gt;

          &lt;section id=&quot;errorCauseSection&quot; class=&quot;modal-section&quot;&gt;
            &lt;h3&gt;발생 가능한 원인&lt;/h3&gt;
            &lt;ul id=&quot;errorCauseList&quot;&gt;&lt;/ul&gt;
          &lt;/section&gt;

          &lt;section id=&quot;errorStepSection&quot; class=&quot;modal-section&quot;&gt;
            &lt;h3&gt;해결 방법&lt;/h3&gt;
            &lt;ol id=&quot;errorStepList&quot;&gt;&lt;/ol&gt;
          &lt;/section&gt;

          &lt;details id=&quot;errorTechnicalDetails&quot; class=&quot;technical-details&quot;&gt;
            &lt;summary&gt;기술 정보 보기&lt;/summary&gt;
            &lt;div id=&quot;errorTechnicalGrid&quot; class=&quot;technical-grid&quot;&gt;&lt;/div&gt;
          &lt;/details&gt;
        &lt;/div&gt;

        &lt;div class=&quot;modal-actions&quot;&gt;
          &lt;a
            id=&quot;errorHelpLink&quot;
            class=&quot;modal-help-link hidden&quot;
            href=&quot;#&quot;
            target=&quot;_blank&quot;
            rel=&quot;noopener noreferrer&quot;
          &gt;&lt;/a&gt;

          &lt;button
            id=&quot;errorModalPrimaryButton&quot;
            class=&quot;primary&quot;
            type=&quot;button&quot;
          &gt;
            확인
          &lt;/button&gt;

          &lt;button
            id=&quot;errorModalDismissButton&quot;
            class=&quot;secondary&quot;
            type=&quot;button&quot;
          &gt;
            닫기
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;script&gt;
      const ERROR_PREFIX = &#39;YT_STRUCTURED_ERROR::&#39;;

      let currentState = null;
      let pollTimer = null;
      let pollInFlight = false;
      let lastShownErrorKey = &#39;&#39;;
      let pendingFocusTarget = &#39;&#39;;

      document.addEventListener(&#39;DOMContentLoaded&#39;, function() {
        bindEvents();
        refreshState(false);
      });

      function bindEvents() {
        document.getElementById(&#39;saveApiButton&#39;).addEventListener(&#39;click&#39;, saveApiKey);
        document.getElementById(&#39;changeApiButton&#39;).addEventListener(&#39;click&#39;, showApiKeyEditor);
        document.getElementById(&#39;deleteApiButton&#39;).addEventListener(&#39;click&#39;, deleteApiKey);
        document.getElementById(&#39;startButton&#39;).addEventListener(&#39;click&#39;, startCollection);
        document.getElementById(&#39;cancelButton&#39;).addEventListener(&#39;click&#39;, cancelCollection);

        document
          .getElementById(&#39;errorModalCloseButton&#39;)
          .addEventListener(&#39;click&#39;, closeErrorModal);

        document
          .getElementById(&#39;errorModalDismissButton&#39;)
          .addEventListener(&#39;click&#39;, closeErrorModal);

        document
          .getElementById(&#39;errorModalPrimaryButton&#39;)
          .addEventListener(&#39;click&#39;, handleErrorModalPrimaryAction);

        document
          .getElementById(&#39;errorModalBackdrop&#39;)
          .addEventListener(&#39;click&#39;, function(event) {
            if (event.target === event.currentTarget) {
              closeErrorModal();
            }
          });

        document.addEventListener(&#39;keydown&#39;, function(event) {
          if (event.key === &#39;Escape&#39;) {
            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, &#39;상태 확인&#39;);
            }
          })
          .getSidebarState();
      }

      function renderState(state) {
        currentState = state || {};

        const configured = Boolean(currentState.apiKeyConfigured);
        const running = Boolean(currentState.running);

        document.getElementById(&#39;apiMissingView&#39;).classList.toggle(&#39;hidden&#39;, configured);
        document.getElementById(&#39;apiSavedView&#39;).classList.toggle(&#39;hidden&#39;, !configured);
        document.getElementById(&#39;apiKeyMask&#39;).textContent = currentState.apiKeyMask || &#39;&#39;;

        document.getElementById(&#39;collectorFieldset&#39;).disabled = !configured || running;
        document.getElementById(&#39;lockedMessage&#39;).classList.toggle(&#39;hidden&#39;, configured);

        document.getElementById(&#39;changeApiButton&#39;).disabled = running;
        document.getElementById(&#39;deleteApiButton&#39;).disabled = running;

        renderProgress(currentState.progress, running);

        if (running) {
          startPolling();
        } else {
          stopPolling();
        }
      }

      function renderProgress(progress, running) {
        const card = document.getElementById(&#39;progressCard&#39;);

        if (!progress) {
          card.classList.add(&#39;hidden&#39;);
          return;
        }

        card.classList.remove(&#39;hidden&#39;);

        const status = progress.status || &#39;&#39;;
        const badge = document.getElementById(&#39;progressBadge&#39;);
        badge.textContent = progress.stage || status;
        badge.className = &#39;badge &#39; + getBadgeClass(status);

        const progressTitle =
          progress.videoTitle || progress.videoId || &#39;YouTube 영상&#39;;
        const outputSheetName = progress.outputSheetName || &#39;&#39;;

        document.getElementById(&#39;progressTitle&#39;).textContent =
          outputSheetName
            ? progressTitle + &#39; · &#39; + outputSheetName
            : progressTitle;

        document.getElementById(&#39;progressFill&#39;).style.width =
          String(progress.percent || 0) + &#39;%&#39;;

        document.getElementById(&#39;topMetric&#39;).textContent =
          formatNumber(progress.topCount) + &#39; / &#39; + formatNumber(progress.targetTopLevelCount);

        document.getElementById(&#39;replyMetric&#39;).textContent = formatNumber(progress.replyCount);
        document.getElementById(&#39;totalMetric&#39;).textContent = formatNumber(progress.totalRows);
        document.getElementById(&#39;apiCallMetric&#39;).textContent = formatNumber(progress.apiCalls);
        document.getElementById(&#39;progressMessage&#39;).textContent = progress.message || &#39;&#39;;
        document.getElementById(&#39;cancelButton&#39;).classList.toggle(&#39;hidden&#39;, !running);

        if (status === &#39;ERROR&#39;) {
          const errorInfo = progress.error || buildFallbackErrorInfo(
            progress.message || &#39;&#39;,
            &#39;댓글 수집&#39;
          );

          const errorKey = [
            errorInfo.code || &#39;UNKNOWN_ERROR&#39;,
            progress.updatedAt || &#39;&#39;,
            progress.videoId || &#39;&#39;
          ].join(&#39;|&#39;);

          if (errorKey !== lastShownErrorKey) {
            lastShownErrorKey = errorKey;
            showErrorModal(errorInfo);
          }
        }
      }

      function saveApiKey() {
        const input = document.getElementById(&#39;apiKey&#39;);
        const button = document.getElementById(&#39;saveApiButton&#39;);
        const apiKey = input.value.trim();

        if (!apiKey) {
          showErrorModal({
            code: &#39;API_KEY_REQUIRED&#39;,
            title: &#39;API 키를 입력해야 합니다.&#39;,
            summary: &#39;API 키 입력란이 비어 있어 검증을 시작할 수 없습니다.&#39;,
            causes: [
              &#39;입력란에 값이 입력되지 않았습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’에서 API 키를 확인합니다.&#39;,
              &#39;키 값을 복사하여 입력란에 붙여 넣습니다.&#39;,
              &#39;‘API 키 확인 후 저장’을 누릅니다.&#39;
            ],
            helpLabel: &#39;API 키 관리 화면 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;API 키 입력&#39;
          });
          return;
        }

        setButtonBusy(button, true, &#39;확인 중&#39;);
        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            input.value = &#39;&#39;;
            setButtonBusy(button, false, &#39;API 키 확인 후 저장&#39;);
            renderState(state);
            showMessage(&#39;API 키를 검증하여 저장했습니다.&#39;, &#39;success&#39;);
          })
          .withFailureHandler(function(error) {
            setButtonBusy(button, false, &#39;API 키 확인 후 저장&#39;);
            handleServerError(error, &#39;API 키 검증&#39;);
          })
          .saveApiKey(apiKey);
      }

      function showApiKeyEditor() {
        document.getElementById(&#39;apiSavedView&#39;).classList.add(&#39;hidden&#39;);
        document.getElementById(&#39;apiMissingView&#39;).classList.remove(&#39;hidden&#39;);
        document.getElementById(&#39;apiKey&#39;).focus();
        showMessage(&#39;새 API 키를 입력하면 기존 키가 교체됩니다.&#39;, &#39;info&#39;);
      }

      function deleteApiKey() {
        if (!window.confirm(&#39;저장된 API 키를 삭제하시겠습니까?&#39;)) {
          return;
        }

        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            renderState(state);
            showMessage(&#39;저장된 API 키를 삭제했습니다.&#39;, &#39;success&#39;);
          })
          .withFailureHandler(function(error) {
            handleServerError(error, &#39;API 키 삭제&#39;);
          })
          .deleteApiKey();
      }

      function startCollection() {
        const videoUrl = document.getElementById(&#39;videoUrl&#39;).value.trim();
        const limitText = document.getElementById(&#39;limit&#39;).value;
        const limit = Number(limitText);
        const startButton = document.getElementById(&#39;startButton&#39;);

        if (!videoUrl) {
          showErrorModal({
            code: &#39;VIDEO_URL_REQUIRED&#39;,
            title: &#39;영상 주소를 입력해야 합니다.&#39;,
            summary: &#39;수집 대상 YouTube 영상 주소가 비어 있습니다.&#39;,
            causes: [
              &#39;영상 주소 입력란에 값이 없습니다.&#39;
            ],
            steps: [
              &#39;YouTube에서 수집할 영상을 엽니다.&#39;,
              &#39;주소창의 전체 영상 주소를 복사하여 입력합니다.&#39;
            ],
            focusTarget: &#39;videoUrl&#39;,
            actionLabel: &#39;영상 주소 입력&#39;
          });
          return;
        }

        if (!Number.isInteger(limit) || limit &lt; 1 || limit &gt; 50000) {
          showErrorModal({
            code: &#39;COMMENT_LIMIT_INVALID&#39;,
            title: &#39;수집 개수를 확인해야 합니다.&#39;,
            summary: &#39;수집 개수는 1~50,000 사이의 정수로 입력해야 합니다.&#39;,
            causes: [
              &#39;빈 값, 소수, 문자 또는 허용 범위를 벗어난 수가 입력되었습니다.&#39;
            ],
            steps: [
              &#39;수집 개수 입력란에 1~50,000 사이의 정수를 입력합니다.&#39;,
              &#39;예: 100, 500, 1000&#39;
            ],
            focusTarget: &#39;limit&#39;,
            actionLabel: &#39;수집 개수 수정&#39;
          });
          return;
        }

        const payload = {
          videoUrl: videoUrl,
          limit: limit,
          includeReplies: document.getElementById(&#39;includeReplies&#39;).checked
        };

        setButtonBusy(startButton, true, &#39;준비 중&#39;);
        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            setButtonBusy(startButton, false, &#39;수집 시작&#39;);
            renderState(state);
            const sheetName =
              state &amp;&amp;
              state.progress &amp;&amp;
              state.progress.outputSheetName
                ? state.progress.outputSheetName
                : &#39;&#39;;

            showMessage(
              sheetName
                ? sheetName + &#39;에서 수집 작업을 시작했습니다.&#39;
                : &#39;수집 작업을 시작했습니다.&#39;,
              &#39;info&#39;
            );
            startPolling();

            // 서버에서 첫 수집 구간을 즉시 실행합니다.
            google.script.run
              .withSuccessHandler(function(updatedState) {
                renderState(updatedState);
              })
              .withFailureHandler(function(error) {
                refreshState(true);
                handleServerError(error, &#39;댓글 수집&#39;);
              })
              .runCollectionNow();
          })
          .withFailureHandler(function(error) {
            setButtonBusy(startButton, false, &#39;수집 시작&#39;);
            handleServerError(error, &#39;수집 준비&#39;);
          })
          .startCollectionFromSidebar(payload);
      }

      function cancelCollection() {
        if (!window.confirm(&#39;현재 수집 작업을 취소하시겠습니까? 이미 저장된 행은 유지됩니다.&#39;)) {
          return;
        }

        clearMessage();

        google.script.run
          .withSuccessHandler(function(state) {
            renderState(state);

            if (state &amp;&amp; state.running) {
              showMessage(&#39;취소 요청을 보냈습니다. 현재 API 페이지 처리가 끝나면 안전하게 중단됩니다.&#39;, &#39;info&#39;);
              startPolling();
            } else {
              showMessage(&#39;수집 작업을 취소했습니다.&#39;, &#39;success&#39;);
            }
          })
          .withFailureHandler(function(error) {
            handleServerError(error, &#39;수집 취소&#39;);
          })
          .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 = &#39;&lt;span class=&quot;row&quot; style=&quot;justify-content:center;&quot;&gt;&lt;span class=&quot;spinner&quot;&gt;&lt;/span&gt;&lt;span&gt;&#39; +
            escapeHtml(label) +
            &#39;&lt;/span&gt;&lt;/span&gt;&#39;;
        } else {
          button.textContent = label;
        }
      }

      function showMessage(text, type) {
        const box = document.getElementById(&#39;message&#39;);
        box.textContent = text || &#39;&#39;;
        box.className = &#39;message show &#39; + (type || &#39;info&#39;);
      }

      function clearMessage() {
        const box = document.getElementById(&#39;message&#39;);
        box.textContent = &#39;&#39;;
        box.className = &#39;message&#39;;
      }

      function getBadgeClass(status) {
        if (status === &#39;COMPLETED&#39;) {
          return &#39;success&#39;;
        }

        if (status === &#39;ERROR&#39; || status === &#39;CANCELLED&#39;) {
          return &#39;danger&#39;;
        }

        return &#39;warning&#39;;
      }

      function handleServerError(error, context) {
        const errorInfo = parseServerError(error, context);
        showErrorModal(errorInfo);
      }

      function parseServerError(error, context) {
        if (error &amp;&amp; error.title &amp;&amp; error.summary) {
          return normalizeClientErrorInfo(error);
        }

        const raw = error &amp;&amp; error.message
          ? String(error.message)
          : String(error || &#39;&#39;);

        const structured = extractStructuredError(raw);

        if (structured) {
          return normalizeClientErrorInfo(structured);
        }

        return buildFallbackErrorInfo(raw, context);
      }

      function extractStructuredError(rawText) {
        const raw = String(rawText || &#39;&#39;);
        const markerIndex = raw.indexOf(ERROR_PREFIX);

        if (markerIndex &lt; 0) {
          return null;
        }

        const objectStart = raw.indexOf(
          &#39;{&#39;,
          markerIndex + ERROR_PREFIX.length
        );

        if (objectStart &lt; 0) {
          return null;
        }

        let depth = 0;
        let inString = false;
        let escaped = false;

        for (let index = objectStart; index &lt; raw.length; index += 1) {
          const character = raw.charAt(index);

          if (inString) {
            if (escaped) {
              escaped = false;
            } else if (character === &#39;\\&#39;) {
              escaped = true;
            } else if (character === &#39;&quot;&#39;) {
              inString = false;
            }

            continue;
          }

          if (character === &#39;&quot;&#39;) {
            inString = true;
            continue;
          }

          if (character === &#39;{&#39;) {
            depth += 1;
          } else if (character === &#39;}&#39;) {
            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 || &#39;UNKNOWN_ERROR&#39;),
          title: String(info.title || &#39;작업을 완료하지 못했습니다.&#39;),
          summary: String(
            info.summary ||
            &#39;처리 중 예상하지 못한 문제가 발생했습니다.&#39;
          ),
          causes: Array.isArray(info.causes)
            ? info.causes.map(String)
            : [],
          steps: Array.isArray(info.steps)
            ? info.steps.map(String)
            : [],
          helpLabel: String(info.helpLabel || &#39;&#39;),
          helpUrl: String(info.helpUrl || &#39;&#39;),
          focusTarget: String(info.focusTarget || &#39;&#39;),
          actionLabel: String(info.actionLabel || &#39;확인&#39;),
          technical:
            info.technical &amp;&amp; typeof info.technical === &#39;object&#39;
              ? info.technical
              : {}
        };
      }

      function buildFallbackErrorInfo(rawText, context) {
        const raw = String(rawText || &#39;&#39;);
        const lower = raw.toLowerCase();
        const technical = {
          errorCode: &#39;UNCLASSIFIED_CLIENT_ERROR&#39;,
          context: String(context || &#39;작업 실행&#39;)
        };

        if (
          lower.indexOf(&#39;api key not valid&#39;) &gt;= 0 ||
          lower.indexOf(&#39;keyinvalid&#39;) &gt;= 0 ||
          lower.indexOf(&#39;api_key_invalid&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;API_KEY_INVALID&#39;,
            title: &#39;API 키를 사용할 수 없습니다.&#39;,
            summary: &#39;입력한 API 키가 정확하지 않거나 현재 사용할 수 없는 상태입니다.&#39;,
            causes: [
              &#39;키 일부가 누락되었거나 다른 값을 붙여 넣었을 수 있습니다.&#39;,
              &#39;Google Cloud에서 기존 키를 삭제하거나 재발급했을 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console의 ‘API 및 서비스 → 사용자 인증 정보’를 엽니다.&#39;,
              &#39;실제 API 키 값을 전체 복사합니다.&#39;,
              &#39;사이드바에서 키를 다시 입력하고 저장합니다.&#39;
            ],
            helpLabel: &#39;API 키 관리 화면 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/credentials&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;API 키 다시 입력&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;API_KEY_INVALID&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;access not configured&#39;) &gt;= 0 ||
          lower.indexOf(&#39;accessnotconfigured&#39;) &gt;= 0 ||
          lower.indexOf(&#39;has not been used in project&#39;) &gt;= 0 ||
          lower.indexOf(&#39;api_disabled&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;YOUTUBE_API_NOT_ENABLED&#39;,
            title: &#39;YouTube Data API v3가 활성화되어 있지 않습니다.&#39;,
            summary: &#39;API 키가 속한 Google Cloud 프로젝트에서 YouTube Data API v3 사용 설정을 켜야 합니다.&#39;,
            causes: [
              &#39;API 키를 만든 프로젝트에서 YouTube Data API v3를 아직 활성화하지 않았습니다.&#39;,
              &#39;API를 활성화한 프로젝트와 API 키를 발급한 프로젝트가 다를 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console에서 API 키를 만든 프로젝트를 선택합니다.&#39;,
              &#39;‘API 및 서비스 → 라이브러리’로 이동합니다.&#39;,
              &#39;‘YouTube Data API v3’를 검색하고 ‘사용’을 누릅니다.&#39;,
              &#39;설정 후 1~5분 뒤 API 키를 다시 저장합니다.&#39;
            ],
            helpLabel: &#39;YouTube Data API 설정 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/library/youtube.googleapis.com&#39;,
            focusTarget: &#39;apiKey&#39;,
            actionLabel: &#39;설정 확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;YOUTUBE_API_NOT_ENABLED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;commentsdisabled&#39;) &gt;= 0 ||
          lower.indexOf(&#39;comments disabled&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;COMMENTS_DISABLED&#39;,
            title: &#39;이 영상에서는 댓글을 수집할 수 없습니다.&#39;,
            summary: &#39;영상 게시자가 댓글 기능을 사용하지 않도록 설정했습니다.&#39;,
            causes: [
              &#39;영상의 댓글 기능이 꺼져 있거나 YouTube 정책에 따라 제한되었습니다.&#39;
            ],
            steps: [
              &#39;영상 페이지에서 댓글 영역이 표시되는지 확인합니다.&#39;,
              &#39;댓글이 허용된 다른 영상 주소를 입력합니다.&#39;
            ],
            focusTarget: &#39;videoUrl&#39;,
            actionLabel: &#39;영상 주소 다시 입력&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;COMMENTS_DISABLED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;quotaexceeded&#39;) &gt;= 0 ||
          lower.indexOf(&#39;quota_exceeded&#39;) &gt;= 0 ||
          lower.indexOf(&#39;daily limit&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;YOUTUBE_QUOTA_EXCEEDED&#39;,
            title: &#39;YouTube API의 오늘 할당량을 모두 사용했습니다.&#39;,
            summary: &#39;현재 프로젝트에서 오늘 사용할 수 있는 YouTube Data API 할당량이 남아 있지 않습니다.&#39;,
            causes: [
              &#39;같은 프로젝트의 다른 작업에서도 YouTube API를 사용했을 수 있습니다.&#39;
            ],
            steps: [
              &#39;Google Cloud Console에서 YouTube Data API의 오늘 사용량을 확인합니다.&#39;,
              &#39;일일 할당량이 갱신된 뒤 다시 실행합니다.&#39;,
              &#39;다음 수집에서는 요청 댓글 수를 줄입니다.&#39;
            ],
            helpLabel: &#39;YouTube API 할당량 열기&#39;,
            helpUrl: &#39;https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas&#39;,
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;YOUTUBE_QUOTA_EXCEEDED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;authorization is required&#39;) &gt;= 0 ||
          lower.indexOf(&#39;you do not have permission&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;,
            title: &#39;Google 권한 승인이 필요합니다.&#39;,
            summary: &#39;현재 계정이 스프레드시트 또는 외부 API 요청 권한을 아직 승인하지 않았습니다.&#39;,
            causes: [
              &#39;Apps Script를 처음 실행했거나 새로운 권한이 추가되었습니다.&#39;
            ],
            steps: [
              &#39;스프레드시트 메뉴에서 사이드바를 다시 엽니다.&#39;,
              &#39;권한 검토 화면에서 현재 계정을 선택합니다.&#39;,
              &#39;요청되는 권한을 허용한 뒤 다시 실행합니다.&#39;
            ],
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;APPS_SCRIPT_AUTH_REQUIRED&#39;
            })
          });
        }

        if (
          lower.indexOf(&#39;timed out&#39;) &gt;= 0 ||
          lower.indexOf(&#39;maximum execution time&#39;) &gt;= 0
        ) {
          return normalizeClientErrorInfo({
            code: &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;,
            title: &#39;한 번의 실행 가능 시간을 초과했습니다.&#39;,
            summary: &#39;댓글이 많아 현재 실행 구간이 종료되었습니다.&#39;,
            causes: [
              &#39;대량 댓글 또는 답글 처리 시간이 길어졌습니다.&#39;
            ],
            steps: [
              &#39;진행 상태가 자동으로 이어지는지 확인합니다.&#39;,
              &#39;멈춘 경우 ‘중단 지점부터 실행’을 한 번 실행합니다.&#39;,
              &#39;반복되면 수집 개수를 줄입니다.&#39;
            ],
            actionLabel: &#39;확인&#39;,
            technical: Object.assign({}, technical, {
              errorCode: &#39;APPS_SCRIPT_EXECUTION_TIMEOUT&#39;
            })
          });
        }

        return normalizeClientErrorInfo({
          code: &#39;UNEXPECTED_ERROR&#39;,
          title: &#39;예상하지 못한 오류가 발생했습니다.&#39;,
          summary: &#39;오류 유형을 자동으로 판별하지 못했습니다. 입력값과 Apps Script 실행 기록을 확인해야 합니다.&#39;,
          causes: [
            &#39;네트워크 연결, Google 서비스 상태, 권한 또는 실행 환경에서 문제가 발생했을 수 있습니다.&#39;
          ],
          steps: [
            &#39;사이드바를 닫았다가 다시 열고 같은 작업을 한 번 더 실행합니다.&#39;,
            &#39;Apps Script 편집기의 왼쪽 ‘실행’ 메뉴에서 실패 기록을 확인합니다.&#39;,
            &#39;API 키 저장 상태와 영상 주소를 다시 확인합니다.&#39;
          ],
          actionLabel: &#39;확인&#39;,
          technical: technical
        });
      }

      function showErrorModal(errorInfo) {
        const info = normalizeClientErrorInfo(errorInfo);
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);
        const title = document.getElementById(&#39;errorModalTitle&#39;);
        const code = document.getElementById(&#39;errorModalCode&#39;);
        const summary = document.getElementById(&#39;errorModalSummary&#39;);
        const causeSection = document.getElementById(&#39;errorCauseSection&#39;);
        const stepSection = document.getElementById(&#39;errorStepSection&#39;);
        const causeList = document.getElementById(&#39;errorCauseList&#39;);
        const stepList = document.getElementById(&#39;errorStepList&#39;);
        const helpLink = document.getElementById(&#39;errorHelpLink&#39;);
        const primaryButton = document.getElementById(&#39;errorModalPrimaryButton&#39;);

        clearMessage();

        title.textContent = info.title;
        code.textContent = &#39;오류 코드: &#39; + info.code;
        summary.textContent = info.summary;

        renderTextList(causeList, info.causes);
        renderTextList(stepList, info.steps);

        causeSection.classList.toggle(&#39;hidden&#39;, info.causes.length === 0);
        stepSection.classList.toggle(&#39;hidden&#39;, info.steps.length === 0);

        if (
          info.helpUrl &amp;&amp;
          /^https:\/\/console\.cloud\.google\.com\//.test(info.helpUrl)
        ) {
          helpLink.href = info.helpUrl;
          helpLink.textContent = info.helpLabel || &#39;Google Cloud 설정 열기&#39;;
          helpLink.classList.remove(&#39;hidden&#39;);
        } else {
          helpLink.removeAttribute(&#39;href&#39;);
          helpLink.textContent = &#39;&#39;;
          helpLink.classList.add(&#39;hidden&#39;);
        }

        primaryButton.textContent = info.actionLabel || &#39;확인&#39;;
        pendingFocusTarget = info.focusTarget || &#39;&#39;;

        renderTechnicalInfo(info.technical);

        backdrop.classList.remove(&#39;hidden&#39;);
        document.body.style.overflow = &#39;hidden&#39;;

        window.setTimeout(function() {
          primaryButton.focus();
        }, 0);
      }

      function renderTextList(listElement, items) {
        listElement.innerHTML = &#39;&#39;;

        (items || []).forEach(function(item) {
          const listItem = document.createElement(&#39;li&#39;);
          listItem.textContent = String(item);
          listElement.appendChild(listItem);
        });
      }

      function renderTechnicalInfo(technical) {
        const details = document.getElementById(&#39;errorTechnicalDetails&#39;);
        const grid = document.getElementById(&#39;errorTechnicalGrid&#39;);
        const data = technical &amp;&amp; typeof technical === &#39;object&#39;
          ? technical
          : {};

        const labels = {
          errorCode: &#39;분류 코드&#39;,
          httpStatus: &#39;HTTP 상태&#39;,
          reason: &#39;API 사유 코드&#39;,
          resource: &#39;요청 자원&#39;,
          videoId: &#39;영상 ID&#39;,
          context: &#39;실행 단계&#39;
        };

        grid.innerHTML = &#39;&#39;;

        Object.keys(labels).forEach(function(key) {
          if (
            data[key] === undefined ||
            data[key] === null ||
            data[key] === &#39;&#39;
          ) {
            return;
          }

          const label = document.createElement(&#39;div&#39;);
          label.className = &#39;technical-label&#39;;
          label.textContent = labels[key];

          const value = document.createElement(&#39;div&#39;);
          value.className = &#39;technical-value&#39;;
          value.textContent = String(data[key]);

          grid.appendChild(label);
          grid.appendChild(value);
        });

        details.classList.toggle(&#39;hidden&#39;, grid.children.length === 0);
        details.open = false;
      }

      function closeErrorModal() {
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);

        if (backdrop.classList.contains(&#39;hidden&#39;)) {
          return;
        }

        backdrop.classList.add(&#39;hidden&#39;);
        document.body.style.overflow = &#39;&#39;;
        pendingFocusTarget = &#39;&#39;;
      }

      function handleErrorModalPrimaryAction() {
        const focusTarget = pendingFocusTarget;
        const backdrop = document.getElementById(&#39;errorModalBackdrop&#39;);

        backdrop.classList.add(&#39;hidden&#39;);
        document.body.style.overflow = &#39;&#39;;
        pendingFocusTarget = &#39;&#39;;

        focusErrorTarget(focusTarget);
      }

      function focusErrorTarget(targetId) {
        if (!targetId) {
          return;
        }

        if (targetId === &#39;apiKey&#39;) {
          document.getElementById(&#39;apiSavedView&#39;).classList.add(&#39;hidden&#39;);
          document.getElementById(&#39;apiMissingView&#39;).classList.remove(&#39;hidden&#39;);
        }

        const target = document.getElementById(targetId);

        if (target) {
          window.setTimeout(function() {
            target.focus();

            if (typeof target.select === &#39;function&#39;) {
              target.select();
            }
          }, 0);
        }
      }

      function formatNumber(value) {
        return Number(value || 0).toLocaleString(&#39;ko-KR&#39;);
      }

      function escapeHtml(value) {
        return String(value || &#39;&#39;)
          .replace(/&amp;/g, &#39;&amp;amp;&#39;)
          .replace(/&lt;/g, &#39;&amp;lt;&#39;)
          .replace(/&gt;/g, &#39;&amp;gt;&#39;)
          .replace(/&quot;/g, &#39;&amp;quot;&#39;)
          .replace(/&#39;/g, &#39;&amp;#039;&#39;);
      }
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</pre></section><h3>관련 링크</h3><ul><li><a href="https://brunch.co.kr/@mystoryg/156">Youtube Data API 키 발급 방법 안내</a></li></ul></section><section><h2>바른 API KEY 발급</h2><h3>관련 링크</h3><ul><li><a href="https://nara-speller.co.kr/speller/">하나씩 검사하기 - 바른한글</a></li><li><a href="https://bareun.ai/api-key">API로 대량 검사하기 - 바른AI</a></li></ul></section><section><h2>민기쌤 생기부 도우미</h2><section><h3>Code.gs</h3><pre>// =================================================================
// [0] 초기 설정 및 메뉴
// =================================================================

function onOpen() {
  SpreadsheetApp.getUi().createMenu(&#39;민기쌤 생기부 도우미&#39;)
      .addItem(&#39;🚀 사이드바 열기&#39;, &#39;openSidebar&#39;) 
      .addToUi();
}

function renderView_(viewName) {
  var template = HtmlService.createTemplateFromFile(&#39;Sidebar&#39;);
  template.initialView = viewName || &#39;sidebar&#39;;
  return template.evaluate();
}

function openSidebar() {
  ensureBannedWordSheet_();
  var html = renderView_(&#39;sidebar&#39;)
      .setTitle(&#39;민기쌤 생기부 맞춤법 도우미&#39;)
      .setWidth(300); 
  SpreadsheetApp.getUi().showSidebar(html);
}

function openSpellCheckViewer() {
  var html = renderView_(&#39;spellcheck&#39;)
      .setWidth(1000) 
      .setHeight(700);
  SpreadsheetApp.getUi().showModelessDialog(html, &#39;📝 Bareun 맞춤법 검사 및 수정&#39;);
}

function openBannedWordViewer() {
  var html = renderView_(&#39;banned&#39;)
      .setWidth(1000)
      .setHeight(700);
  SpreadsheetApp.getUi().showModelessDialog(html, &#39;🚫 기재 금지어 점검&#39;);
}

// =================================================================
// [1] Bareun API 키 및 UI 설정
// =================================================================

function getBareunUserProperties_() {
  ensureBannedWordSheet_();
  var props = PropertiesService.getUserProperties();
  var currentPolicy = &#39;BAREUN_ONLY_KEYS_V3&#39;;
  var previousPolicy = props.getProperty(&#39;API_KEY_POLICY_VERSION&#39;);

  // 정책 변경 시 현재 기능에서 쓰지 않는 예전 사용자 설정을 한 번 정리합니다.
  if (previousPolicy !== currentPolicy) {
    var allowedProperties = {
      UI_STATE: true,
      API_KEY_POLICY_VERSION: true
    };
    // 바로 이전 사용자 입력형 정책에서 저장한 키만 보존합니다.
    if (previousPolicy === &#39;USER_SUPPLIED_KEYS_V1&#39; ||
        previousPolicy === &#39;BAREUN_ONLY_KEYS_V1&#39; ||
        previousPolicy === &#39;BAREUN_ONLY_KEYS_V2&#39;) {
      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(&#39;UI_STATE&#39;) || &#39;{}&#39;);
    } catch (e) {
      previousUiState = {};
    }
    var currentUiState = {};
    [&#39;card-api&#39;, &#39;sub-api-settings&#39;, &#39;card-util&#39;].forEach(function(id) {
      if (previousUiState[id]) currentUiState[id] = true;
    });
    props.setProperty(&#39;UI_STATE&#39;, JSON.stringify(currentUiState));
    props.setProperty(&#39;API_KEY_POLICY_VERSION&#39;, currentPolicy);
  }

  return props;
}

function saveUserBareunKey(bareunKey) {
  var props = getBareunUserProperties_();
  var cleanBareunKey = String(bareunKey || &#39;&#39;).trim();

  if (!cleanBareunKey) {
    throw new Error(&#39;Bareun API 키를 입력해 주세요. 이 도우미는 API 키를 제공하지 않습니다.&#39;);
  }

  props.setProperty(&#39;USER_BAREUN_KEY&#39;, cleanBareunKey);
  return { hasBareun: true };
}

function checkUserBareunStatus() {
  var props = getBareunUserProperties_();
  return { hasBareun: !!props.getProperty(&#39;USER_BAREUN_KEY&#39;) };
}

function clearUserBareunKey() {
  var props = getBareunUserProperties_();
  props.deleteProperty(&#39;USER_BAREUN_KEY&#39;);
  return { hasBareun: false };
}

function getBareunKey() {
  var key = getBareunUserProperties_().getProperty(&#39;USER_BAREUN_KEY&#39;);
  if (!key) throw new Error(&quot;Bareun API 키가 없습니다. 사이드바 상단에서 등록해주세요.&quot;);
  return key;
}

// =================================================================
// [2] Bareun 맞춤법 검사
// =================================================================

function getSelectedDataForSpellCheck() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var sheetName = sheet.getName();
  var range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);
  var rows = [];
  var startRow = range.getRow();
  var startCol = range.getColumn();
  var values = range.getValues();
  for (var i = 0; i &lt; values.length; i++) {
    for (var j = 0; j &lt; values[0].length; j++) {
      var text = values[i][j].toString();
      if (text.trim() !== &quot;&quot;) {
        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(&#39;검사한 시트를 찾을 수 없습니다: &#39; + item.sheetName);
    sheet.getRange(item.row, item.col).setValue(item.text);
  });
  return updates.length;
}

function callBareunApi(text, userKey) {
  var url = &quot;https://api.bareun.ai/bareun.RevisionService/CorrectError&quot;;
  var options = {
    &quot;method&quot;: &quot;post&quot;, &quot;contentType&quot;: &quot;application/json&quot;, &quot;headers&quot;: { &quot;api-key&quot;: userKey }, &quot;muteHttpExceptions&quot;: true,
    &quot;payload&quot;: JSON.stringify({ &quot;document&quot;: { &quot;content&quot;: text, &quot;language&quot;: &quot;ko-KR&quot; }, &quot;encoding_type&quot;: &quot;UTF16&quot; })
  };
  var response = UrlFetchApp.fetch(url, options);
  var result = JSON.parse(response.getContentText());
  
  if (response.getResponseCode() !== 200) {
    return { error: &quot;API 오류: &quot; + response.getResponseCode() };
  }
  return { revised: result.revised, helps: result.helps || {}, rawBlocks: result.revisedBlocks || result.revised_blocks || [], error: null };
}

// =================================================================
// [3] 기재 금지어 관리
// =================================================================

var BANNED_WORD_SHEET_NAME_ = &#39;기재금지어관리&#39;;
var BANNED_WORD_HEADERS_ = [&#39;유형&#39;, &#39;기재 금지어&#39;, &#39;대체 표현&#39;];

function getLegacyBannedWordList_() {
  var saved = PropertiesService.getUserProperties().getProperty(&#39;BANNED_WORDS_DB&#39;);
  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 &amp;&amp; item.k || &#39;&#39;).trim(),
          v: String(item &amp;&amp; item.v || &#39;&#39;).trim(),
          t: item &amp;&amp; item.t === &#39;ban&#39; ? &#39;ban&#39; : &#39;replace&#39;
        };
      })
      .filter(function(item) { return item.k !== &#39;&#39;; });
  } catch (e) {
    return getDefaultBannedList_();
  }
}

function initializeBannedWordSheet_(sheet, list) {
  sheet.getRange(1, 1, 1, BANNED_WORD_HEADERS_.length)
    .setValues([BANNED_WORD_HEADERS_])
    .setBackground(&#39;#fce8e6&#39;)
    .setFontColor(&#39;#a50e0e&#39;)
    .setFontWeight(&#39;bold&#39;)
    .setHorizontalAlignment(&#39;center&#39;);

  var rows = list.map(function(item) {
    return [item.t === &#39;ban&#39; ? &#39;불가&#39; : &#39;대체&#39;, item.k, item.t === &#39;ban&#39; ? &#39;&#39; : item.v];
  });
  if (rows.length &gt; 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(&#39;#d93025&#39;);

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

function ensureBannedWordSheet_() {
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  if (!spreadsheet) throw new Error(&#39;활성 스프레드시트를 찾을 수 없습니다.&#39;);

  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(&#39;BANNED_WORDS_DB&#39;);
    return sheet;
  } finally {
    if (originalSheet &amp;&amp; sheet &amp;&amp; originalSheet.getSheetId() !== sheet.getSheetId()) {
      spreadsheet.setActiveSheet(originalSheet);
      if (originalRange) spreadsheet.setActiveRange(originalRange);
    }
    lock.releaseLock();
  }
}

function openBannedWordManagementSheet() {
  var sheet = ensureBannedWordSheet_();
  sheet.activate();
  sheet.getRange(&#39;A1&#39;).activate();
  return sheet.getName();
}

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

function getBannedWordList_() {
  var sheet = ensureBannedWordSheet_();
  var header = sheet.getRange(1, 1, 1, 3).getDisplayValues()[0];
  if (header.join(&#39;|&#39;) !== BANNED_WORD_HEADERS_.join(&#39;|&#39;)) {
    throw new Error(&#39;기재금지어관리 시트의 1행을 &quot;유형 | 기재 금지어 | 대체 표현&quot; 순서로 유지해 주세요.&#39;);
  }

  var lastRow = sheet.getLastRow();
  if (lastRow &lt; 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] || &#39;&#39;).trim();
    var keyword = String(row[1] || &#39;&#39;).trim();
    var replacement = String(row[2] || &#39;&#39;).trim();

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

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

  return list;
}

function runBannedWordCheck() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var sheetName = sheet.getName();
  var range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);

  var values = range.getValues();
  var startRow = range.getRow();
  var startCol = range.getColumn();
  
  var db = getBannedWordList_();
  db.sort((a, b) =&gt; b.k.length - a.k.length);

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

  var results = [];

  for (var i = 0; i &lt; values.length; i++) {
    for (var j = 0; j &lt; 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 &lt; patterns.length; p++) {
        var item = patterns[p];
        if (item.regex.test(revised)) {
          hasIssue = true;
          var alreadyLogged = false;
          for(var l=0; l&lt;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 === &quot;replace&quot;) {
            revised = revised.replace(item.regex, item.v);
          } else if (item.t === &quot;ban&quot;) {
            revised = revised.replace(item.regex, &quot;&quot;); 
          }
        }
      }
      
      revised = revised.replace(/\s+/g, &#39; &#39;).trim();

      if (hasIssue &amp;&amp; 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, &#39;\\$&amp;&#39;); 
}

// =================================================================
// [4] 로컬 검토 유틸리티
// =================================================================

function unifyToStraightQuotes() {
  var range = SpreadsheetApp.getActiveRange();
  var ui = SpreadsheetApp.getUi();
  if (!range) throw new Error(&#39;선택된 셀이 없습니다.&#39;);
  var response = ui.alert(&#39;따옴표 변환&#39;, &#39;선택 영역의 둥근 따옴표를 곧은 따옴표로 변환하시겠습니까?&#39;, ui.ButtonSet.YES_NO);
  if (response != ui.Button.YES) return;
  try {
    range.createTextFinder(&#39;[‘’]&#39;).useRegularExpression(true).matchEntireCell(false).replaceAllWith(&quot;&#39;&quot;);
    range.createTextFinder(&#39;[“”]&#39;).useRegularExpression(true).matchEntireCell(false).replaceAllWith(&#39;&quot;&#39;);
    SpreadsheetApp.flush();
    ui.alert(&#39;✅ 변환 완료&#39;);
  } catch (e) { ui.alert(&#39;오류: &#39; + e.toString()); }
}

// [신규] 선택 영역 스페이스바 실수(2칸 이상) 수정 함수
function fixDoubleSpaces() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const range = sheet.getActiveRange();
  if (!range) throw new Error(&quot;선택된 셀이 없습니다.&quot;);

  const values = range.getValues();
  let fixCount = 0; // 수정된 셀 개수 카운트

  const newValues = values.map(row =&gt; {
    return row.map(cell =&gt; {
      let str = String(cell);
      // 공백이 2번 이상 연속( /  +/)으로 있으면 1번으로 변경
      // 줄바꿈(\n)은 건드리지 않도록 일반 공백( )만 타겟팅
      if (/ {2,}/.test(str)) {
        fixCount++;
        return str.replace(/ {2,}/g, &#39; &#39;); 
      }
      return cell;
    });
  });

  if (fixCount &gt; 0) {
    range.setValues(newValues);
  }
  
  return fixCount;
}

// =================================================================
// [5] UI 상태 저장/불러오기
// =================================================================

function saveUiState(cardId, isExpanded) {
  var props = PropertiesService.getUserProperties();
  var state = JSON.parse(props.getProperty(&#39;UI_STATE&#39;) || &#39;{}&#39;);
  state[cardId] = isExpanded;
  props.setProperty(&#39;UI_STATE&#39;, JSON.stringify(state));
}

function getUiState() {
  var props = PropertiesService.getUserProperties();
  return JSON.parse(props.getProperty(&#39;UI_STATE&#39;) || &#39;{}&#39;);
}

</pre></section><section><h3>Sidebar.html</h3><pre>&lt;? var allowedViews = [&quot;sidebar&quot;, &quot;spellcheck&quot;, &quot;banned&quot;]; ?&gt;
&lt;? var activeView = allowedViews.indexOf(initialView) &gt;= 0 ? initialView : &quot;sidebar&quot;; ?&gt;
&lt;? if (activeView === &#39;sidebar&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;style&gt;
      * { box-sizing: border-box; }
      body {
        margin: 0;
        padding: 10px 15px 10px 2px;
        background: #f1f3f4;
        color: #202124;
        font-family: &#39;Noto Sans KR&#39;, 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=&quot;password&quot;] {
        width: 100%;
        padding: 10px;
        border: 2px solid #dadce0;
        border-radius: 6px;
        outline: none;
        font-family: inherit;
        font-size: 14px;
      }
      input[type=&quot;password&quot;]: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; }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div class=&quot;card&quot; id=&quot;card-api&quot;&gt;
      &lt;div class=&quot;card-header&quot; onclick=&quot;toggleCard(this)&quot;&gt;
        &lt;div class=&quot;header-title&quot;&gt;🔑 Bareun API 키 설정&lt;/div&gt;
        &lt;span class=&quot;material-icons toggle-icon&quot;&gt;expand_more&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class=&quot;card-body&quot;&gt;
        &lt;div class=&quot;sub-section&quot; id=&quot;sub-api-settings&quot;&gt;
          &lt;div class=&quot;sub-header&quot; onclick=&quot;toggleSub(this)&quot;&gt;
            &lt;span&gt;내 Bareun API 키&lt;/span&gt;
            &lt;span class=&quot;material-icons sub-icon&quot;&gt;expand_more&lt;/span&gt;
          &lt;/div&gt;
          &lt;div class=&quot;sub-body&quot;&gt;
            &lt;div id=&quot;api-loading&quot; class=&quot;api-status&quot;&gt;⌛ API 키 설정 확인 중...&lt;/div&gt;
            &lt;div id=&quot;api-settings&quot; class=&quot;hidden&quot;&gt;
              &lt;div id=&quot;apiStatusSummary&quot; class=&quot;api-status&quot;&gt;&lt;/div&gt;
              &lt;label class=&quot;input-label&quot; for=&quot;bareunKeyInput&quot;&gt;Bareun API 키&lt;/label&gt;
              &lt;input type=&quot;password&quot; id=&quot;bareunKeyInput&quot; autocomplete=&quot;off&quot; placeholder=&quot;본인이 발급받은 키를 입력하세요&quot;&gt;
              &lt;div class=&quot;key-help&quot;&gt;공용 키는 제공하지 않습니다. 키는 사용자별 설정에 저장되고 원문을 화면으로 다시 불러오지 않습니다.&lt;/div&gt;
              &lt;button class=&quot;btn-auth&quot; id=&quot;saveKeyBtn&quot; onclick=&quot;saveBareunKey()&quot;&gt;내 Bareun 키 저장&lt;/button&gt;
              &lt;button class=&quot;btn-secondary hidden&quot; id=&quot;clearKeyBtn&quot; onclick=&quot;resetBareunKey()&quot;&gt;저장된 Bareun 키 삭제&lt;/button&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;card expanded&quot; id=&quot;card-util&quot;&gt;
      &lt;div class=&quot;card-header&quot; onclick=&quot;toggleCard(this)&quot;&gt;
        &lt;div class=&quot;header-title&quot;&gt;📝 선택 영역 검사 및 정리&lt;/div&gt;
        &lt;span class=&quot;material-icons toggle-icon&quot;&gt;expand_more&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class=&quot;card-body&quot;&gt;
        &lt;button class=&quot;btn-warning&quot; id=&quot;checkBtn&quot; onclick=&quot;runSpellCheck()&quot;&gt;선택 영역 Bareun 맞춤법 검사 🔍&lt;/button&gt;
        &lt;div id=&quot;checkStatus&quot; class=&quot;status-box&quot;&gt;&lt;/div&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;runSpaceFix(this)&quot;&gt;선택 영역 연속 공백 정리&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;convertQuotes(this)&quot;&gt;선택 영역 따옴표 통일 (&#39; &quot;)&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;runBannedCheck()&quot;&gt;선택 영역 기재 금지어 검사&lt;/button&gt;
        &lt;button class=&quot;btn-secondary&quot; onclick=&quot;openBannedWordManagement()&quot;&gt;기재금지어관리 시트 열기&lt;/button&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let currentApiStatus = { hasBareun: false };

      function updateApiState(status) {
        currentApiStatus = { hasBareun: !!(status &amp;&amp; status.hasBareun) };

        const summary = document.getElementById(&#39;apiStatusSummary&#39;);
        const clearButton = document.getElementById(&#39;clearKeyBtn&#39;);
        const spellCheckButton = document.getElementById(&#39;checkBtn&#39;);
        const apiCard = document.getElementById(&#39;card-api&#39;);
        const apiSub = document.getElementById(&#39;sub-api-settings&#39;);

        summary.innerHTML = currentApiStatus.hasBareun
          ? &#39;&lt;div class=&quot;ok&quot;&gt;✅ Bareun 키 저장됨&lt;/div&gt;&#39;
          : &#39;&lt;div class=&quot;missing&quot;&gt;❌ 맞춤법 검사를 사용하려면 Bareun 키가 필요합니다.&lt;/div&gt;&#39;;
        clearButton.classList.toggle(&#39;hidden&#39;, !currentApiStatus.hasBareun);
        spellCheckButton.disabled = !currentApiStatus.hasBareun;
        spellCheckButton.title = currentApiStatus.hasBareun ? &#39;&#39; : &#39;Bareun API 키를 먼저 저장해 주세요.&#39;;

        apiCard.classList.toggle(&#39;expanded&#39;, !currentApiStatus.hasBareun);
        apiSub.classList.toggle(&#39;expanded&#39;, !currentApiStatus.hasBareun);
      }

      function toggleCard(header) {
        const card = header.parentElement;
        const isExpanded = card.classList.toggle(&#39;expanded&#39;);
        google.script.run.saveUiState(card.id, isExpanded);
      }

      function toggleSub(header) {
        const section = header.parentElement;
        const isExpanded = section.classList.toggle(&#39;expanded&#39;);
        google.script.run.saveUiState(section.id, isExpanded);
      }

      function saveBareunKey() {
        const button = document.getElementById(&#39;saveKeyBtn&#39;);
        const keyInput = document.getElementById(&#39;bareunKeyInput&#39;);
        const key = keyInput.value.trim();
        if (!key) {
          alert(&#39;Bareun API 키를 입력해 주세요.&#39;);
          return;
        }

        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;저장 중...&#39;;

        google.script.run
          .withSuccessHandler(function(status) {
            keyInput.value = &#39;&#39;;
            updateApiState(status);
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;✅ Bareun API 키를 사용자 설정에 저장했습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;Bareun API 키 저장 오류: &#39; + error);
          })
          .saveUserBareunKey(key);
      }

      function resetBareunKey() {
        if (!confirm(&#39;저장된 Bareun API 키를 삭제하시겠습니까?&#39;)) return;

        google.script.run
          .withSuccessHandler(function(status) {
            document.getElementById(&#39;bareunKeyInput&#39;).value = &#39;&#39;;
            updateApiState(status);
            alert(&#39;저장된 Bareun API 키를 삭제했습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            alert(&#39;Bareun API 키 삭제 오류: &#39; + error);
          })
          .clearUserBareunKey();
      }

      function runSpellCheck() {
        if (!currentApiStatus.hasBareun) {
          alert(&#39;Bareun API 키를 먼저 저장해 주세요.&#39;);
          return;
        }
        document.getElementById(&#39;checkStatus&#39;).innerText = &#39;맞춤법 검사 창을 여는 중...&#39;;
        google.script.run
          .withSuccessHandler(function() {
            document.getElementById(&#39;checkStatus&#39;).innerText = &#39;&#39;;
          })
          .withFailureHandler(function(error) {
            document.getElementById(&#39;checkStatus&#39;).innerText = &#39;&#39;;
            alert(&#39;오류 발생: &#39; + error);
          })
          .openSpellCheckViewer();
      }

      function runSpaceFix(button) {
        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;정리 중...&#39;;
        google.script.run
          .withSuccessHandler(function(count) {
            button.disabled = false;
            button.innerText = originalText;
            alert(count ? &#39;✅ 총 &#39; + count + &#39;개 셀의 연속 공백을 정리했습니다.&#39; : &#39;수정할 연속 공백이 없습니다.&#39;);
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;오류 발생: &#39; + error);
          })
          .fixDoubleSpaces();
      }

      function convertQuotes(button) {
        const originalText = button.innerText;
        button.disabled = true;
        button.innerText = &#39;변환 중...&#39;;
        google.script.run
          .withSuccessHandler(function() {
            button.disabled = false;
            button.innerText = originalText;
          })
          .withFailureHandler(function(error) {
            button.disabled = false;
            button.innerText = originalText;
            alert(&#39;오류 발생: &#39; + error);
          })
          .unifyToStraightQuotes();
      }

      function runBannedCheck() {
        google.script.run
          .withFailureHandler(function(error) { alert(&#39;오류 발생: &#39; + error); })
          .openBannedWordViewer();
      }

      function openBannedWordManagement() {
        google.script.run
          .withFailureHandler(function(error) { alert(&#39;오류 발생: &#39; + error); })
          .openBannedWordManagementSheet();
      }

      window.onload = function() {
        google.script.run
          .withSuccessHandler(function(status) {
            document.getElementById(&#39;api-loading&#39;).classList.add(&#39;hidden&#39;);
            document.getElementById(&#39;api-settings&#39;).classList.remove(&#39;hidden&#39;);
            updateApiState(status);

            google.script.run.withSuccessHandler(function(states) {
              Object.keys(states || {}).forEach(function(id) {
                const element = document.getElementById(id);
                if (id === &#39;card-api&#39; || id === &#39;sub-api-settings&#39;) return;
                if (element &amp;&amp; states[id]) element.classList.add(&#39;expanded&#39;);
              });
              updateApiState(status);
            }).getUiState();
          })
          .withFailureHandler(function(error) {
            document.getElementById(&#39;api-loading&#39;).classList.add(&#39;hidden&#39;);
            document.getElementById(&#39;api-settings&#39;).classList.remove(&#39;hidden&#39;);
            updateApiState({ hasBareun: false });
            document.getElementById(&#39;apiStatusSummary&#39;).textContent = &#39;설정 확인 오류: &#39; + error;
          })
          .checkUserBareunStatus();
      };
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } else if (activeView === &#39;spellcheck&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch.js&quot;&gt;&lt;/script&gt;
    &lt;style&gt;
      body { padding: 20px; font-family: &#39;Noto Sans KR&#39;, 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; }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;

    &lt;div class=&quot;header-area&quot;&gt;
      &lt;div class=&quot;title&quot;&gt;&lt;span class=&quot;material-icons&quot;&gt;fact_check&lt;/span&gt; 맞춤법 검사 및 수정&lt;/div&gt;
      &lt;div style=&quot;font-size:12px; color:#5f6368; display:flex; gap:15px;&quot;&gt;
        &lt;div&gt;&lt;span style=&quot;background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;&quot;&gt;원본 오류&lt;/span&gt;&lt;/div&gt;
        &lt;div&gt;&lt;span style=&quot;background:#e6f4ea; color:#188038; border-bottom:2px solid #188038; padding:0 4px; font-weight:500;&quot;&gt;수정 제안&lt;/span&gt;&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div id=&quot;mainContent&quot; class=&quot;content-area&quot;&gt;
      &lt;div id=&quot;loaderOverlay&quot; class=&quot;loader-overlay&quot;&gt;
        &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
        &lt;div id=&quot;loadingText&quot;&gt;데이터를 불러오는 중입니다...&lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;cardsContainer&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;footer-bar&quot;&gt;
      &lt;button class=&quot;btn btn-cancel&quot; onclick=&quot;google.script.host.close()&quot;&gt;닫기&lt;/button&gt;
      &lt;button class=&quot;btn btn-apply&quot; id=&quot;btnApply&quot; onclick=&quot;applyAll()&quot; disabled&gt;적용하기 (0건)&lt;/button&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let itemData = []; 
      let isFirstRender = true; 

      window.onload = function() {
        google.script.run
          .withSuccessHandler(processData)
          .withFailureHandler(function(err) {
             document.getElementById(&#39;loaderOverlay&#39;).innerHTML = 
               `&lt;div class=&quot;error-msg&quot;&gt;❌ 초기화 오류&lt;br&gt;${err}&lt;br&gt;선택된 셀이 있는지 확인해주세요.&lt;/div&gt;`;
          })
          .getSelectedDataForSpellCheck();
      };

      function processData(rows) {
        if (rows.length === 0) {
          document.getElementById(&#39;loaderOverlay&#39;).innerHTML = &#39;&lt;div class=&quot;error-msg&quot;&gt;선택된 셀이 없거나 빈 셀입니다.&lt;/div&gt;&#39;;
          return;
        }

        document.getElementById(&#39;loadingText&#39;).innerText = `총 ${rows.length}개 항목 분석 중...`;
        const container = document.getElementById(&#39;cardsContainer&#39;);
        container.innerHTML = &quot;&quot;; 
        
        let processedCount = 0;
        const total = rows.length;

        rows.forEach((row, index) =&gt; {
          const div = document.createElement(&#39;div&#39;);
          div.id = `item-${index}`;
          div.className = &#39;check-item&#39;;
          div.innerHTML = `
            &lt;div class=&quot;item-header&quot;&gt;&lt;span&gt;행 ${row.row} (대기 중...)&lt;/span&gt;&lt;/div&gt;
            &lt;div class=&quot;split-view&quot;&gt;&lt;div class=&quot;pane pane-left&quot;&gt;...&lt;/div&gt;&lt;div class=&quot;pane pane-right&quot;&gt;...&lt;/div&gt;&lt;/div&gt;
          `;
          container.appendChild(div);

          google.script.run
            .withSuccessHandler((res) =&gt; {
              renderResult(index, row, res);
              processedCount++;
              updateApplyButton(processedCount, total);
            })
            .withFailureHandler((err) =&gt; {
              renderError(index, row, err);
              processedCount++;
              updateApplyButton(processedCount, total);
            })
            .getBareunCorrection(row.original);
        });
      }

      function renderError(index, rowData, errorMsg) {
        if (isFirstRender) {
          document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
          isFirstRender = false;
        }
        const itemDiv = document.getElementById(`item-${index}`);
        if (!itemDiv) return;
        itemDiv.innerHTML = `&lt;div style=&quot;padding:20px; color:red;&quot;&gt;❌ 분석 실패: ${errorMsg}&lt;/div&gt;`;
      }

      function renderResult(index, rowData, apiResult) {
        if (isFirstRender) {
          document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
          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 = &quot;&quot;;
        const analysisList = [];
        if (apiResult.rawBlocks &amp;&amp; apiResult.rawBlocks.length &gt; 0) {
          traverseBlocks(apiResult.rawBlocks, apiResult.helps, analysisList);
        }

        if (analysisList.length &gt; 0) {
          const listItems = analysisList.map(item =&gt; `
            &lt;li class=&quot;analysis-row&quot;&gt;
              &lt;span class=&quot;err-word&quot;&gt;${item.origin}&lt;/span&gt;
              &lt;span class=&quot;arrow&quot;&gt;→&lt;/span&gt;
              &lt;span class=&quot;fix-word&quot;&gt;${item.revised}&lt;/span&gt;
              &lt;span class=&quot;fix-reason&quot;&gt;${item.msg}&lt;/span&gt;
            &lt;/li&gt;
          `).join(&quot;&quot;);

          helpListHtml = `
            &lt;div class=&quot;analysis-box&quot;&gt;
              &lt;div class=&quot;analysis-title&quot;&gt;&lt;span class=&quot;material-icons&quot; style=&quot;font-size:14px;&quot;&gt;info&lt;/span&gt; 상세 수정 내역 (${analysisList.length}건)&lt;/div&gt;
              &lt;ul class=&quot;analysis-list&quot;&gt;${listItems}&lt;/ul&gt;
            &lt;/div&gt;
          `;
        }

        itemDiv.innerHTML = `
          &lt;div class=&quot;item-header&quot;&gt;
            &lt;span&gt;행 ${rowData.row}&lt;/span&gt;
            &lt;span style=&quot;color:#1a73e8;&quot;&gt;분석 완료&lt;/span&gt;
          &lt;/div&gt;
          &lt;div class=&quot;split-view&quot;&gt;
            &lt;div class=&quot;pane pane-left&quot;&gt;${diff.left}&lt;/div&gt;
            &lt;div class=&quot;pane pane-right&quot; contenteditable=&quot;true&quot; spellcheck=&quot;false&quot; id=&quot;edit-${index}&quot; oninput=&quot;updateBytes(${index})&quot;&gt;${diff.right}&lt;/div&gt;
          &lt;/div&gt;
          ${helpListHtml}
          &lt;div class=&quot;byte-info&quot;&gt;
            &lt;div class=&quot;byte-box&quot; style=&quot;border-right:1px solid #eee;&quot;&gt;원본: ${getByteLength(rowData.original)} byte&lt;/div&gt;
            &lt;div class=&quot;byte-box&quot;&gt;수정본: &lt;span id=&quot;byte-${index}&quot; class=&quot;byte-val&quot;&gt;${getByteLength(apiResult.revised)}&lt;/span&gt; byte&lt;/div&gt;
          &lt;/div&gt;
        `;
        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 &amp;&amp; block.nested.length &gt; 0) {
            traverseBlocks(block.nested, helps, resultsArray);
          } else {
            const origin = block.origin.content || &quot;&quot;;
            const revised = block.revised || &quot;&quot;;
            if (origin === revised) return;
            let msg = &quot;교정&quot;;
            if (block.revisions &amp;&amp; block.revisions.length &gt; 0) {
              const helpId = block.revisions[0].helpId || block.revisions[0].help_id;
              if (helps &amp;&amp; !helps[helpId]) return;
              if (helpId &amp;&amp; helps &amp;&amp; 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 === &#39;undefined&#39;) {
           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 = &quot;&quot;;
          for (var i = 0; i &lt; 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 = &quot;&quot;;
        let rightHtml = &quot;&quot;;
        
        diffs.forEach(function(part) {
          var op = part[0]; 
          var chars = part[1];
          var text = &quot;&quot;;
          
          // 유니코드를 다시 원래 단어로 복원
          for (var i = 0; i &lt; chars.length; i++) {
            text += tokenArr[chars.charCodeAt(i)];
          }
          
          if (op === 0) {
            leftHtml += text; rightHtml += text;
          } else if (op === -1) {
            leftHtml += `&lt;span class=&quot;diff-del&quot;&gt;${text}&lt;/span&gt;`;
          } else if (op === 1) {
            rightHtml += `&lt;span class=&quot;diff-add&quot;&gt;${text}&lt;/span&gt;`;
          }
        });
        
        return { left: leftHtml, right: rightHtml };
      }

      function getByteLength(s) {
        let b = 0; if (!s) return 0;
        for (let i = 0; i &lt; s.length; i++) {
          const c = s.charCodeAt(i);
          if (c === 10) b += 2; else if (c &gt;&gt; 11) b += 3; else if (c &gt;&gt; 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(&#39;btnApply&#39;);
        btn.innerText = `적용하기 (${current} / ${total})`;
        if (current === total) btn.disabled = false;
      }

      function applyAll() {
        if (!confirm(&quot;검사된 내용으로 원본 데이터를 덮어쓰시겠습니까?\n(되돌릴 수 없습니다)&quot;)) return;
        const updates = [];
        itemData.forEach((item, index) =&gt; {
          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(&#39;btnApply&#39;);
        btn.disabled = true; btn.innerText = &quot;저장 중...&quot;;
        google.script.run.withSuccessHandler((count) =&gt; {
          alert(`✅ 총 ${count}건이 수정되었습니다.`);
          google.script.host.close();
        }).applySpellCheckResults(updates);
      }
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } else if (activeView === &#39;banned&#39;) { ?&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;base target=&quot;_top&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;link href=&quot;https://fonts.googleapis.com/icon?family=Material+Icons&quot; rel=&quot;stylesheet&quot;&gt;
    &lt;style&gt;
      body { padding: 20px; font-family: &#39;Noto Sans KR&#39;, 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: &quot;•&quot;; 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); } }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;

    &lt;div class=&quot;header-area&quot;&gt;
      &lt;div class=&quot;title&quot;&gt;&lt;span class=&quot;material-icons&quot;&gt;block&lt;/span&gt; 기재 금지어 검사&lt;/div&gt;
      &lt;div id=&quot;checkLegend&quot; style=&quot;font-size:12px; color:#5f6368; display:flex; gap:15px;&quot;&gt;
        &lt;div&gt;&lt;span style=&quot;background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;&quot;&gt;금지어 발견&lt;/span&gt;&lt;/div&gt;
        &lt;div&gt;&lt;span style=&quot;background:#e6f4ea; color:#188038; border-bottom:2px solid #188038; padding:0 4px; font-weight:500;&quot;&gt;대체어 제안&lt;/span&gt;&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div id=&quot;checkView&quot; class=&quot;content-area&quot;&gt;
      &lt;div id=&quot;loaderOverlay&quot; class=&quot;loader-overlay&quot;&gt;
        &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
        &lt;div id=&quot;loadingText&quot;&gt;데이터를 분석 중입니다...&lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;cardsContainer&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;footer-bar&quot; id=&quot;footerCheck&quot;&gt;
      &lt;button class=&quot;btn btn-cancel&quot; onclick=&quot;google.script.host.close()&quot;&gt;닫기&lt;/button&gt;
      &lt;button class=&quot;btn btn-apply&quot; id=&quot;btnApply&quot; onclick=&quot;applyAll()&quot; disabled&gt;적용하기 (0건)&lt;/button&gt;
    &lt;/div&gt;

    &lt;script&gt;
      let itemData = []; 
      let renderQueue = [];
      const CHUNK_SIZE = 20;

      window.onload = function() {
        runCheck();
      };

      function runCheck() {
        document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;flex&#39;;
        document.getElementById(&#39;loadingText&#39;).innerText = &quot;데이터를 분석 중입니다...&quot;;
        document.getElementById(&#39;cardsContainer&#39;).innerHTML = &quot;&quot;;
        
        google.script.run
          .withSuccessHandler(startRenderSequence)
          .withFailureHandler(err =&gt; alert(&quot;오류: &quot; + err))
          .runBannedWordCheck();
      }

      function startRenderSequence(rows) {
        document.getElementById(&#39;loaderOverlay&#39;).style.display = &#39;none&#39;;
        const container = document.getElementById(&#39;cardsContainer&#39;);
        
        if (!rows || rows.length === 0) {
          container.innerHTML = &#39;&lt;div style=&quot;text-align:center; padding:50px; color:#5f6368;&quot;&gt;금지어가 발견되지 않았습니다. 🎉&lt;/div&gt;&#39;;
          document.getElementById(&#39;btnApply&#39;).disabled = true;
          return;
        }

        itemData = rows; 
        renderQueue = [...rows]; 
        processRenderQueue(); 

        document.getElementById(&#39;btnApply&#39;).innerText = `적용하기 (${rows.length}건)`;
        document.getElementById(&#39;btnApply&#39;).disabled = false;
      }

      function processRenderQueue() {
        if (renderQueue.length === 0) return;

        const container = document.getElementById(&#39;cardsContainer&#39;);
        const chunk = renderQueue.splice(0, CHUNK_SIZE);
        
        chunk.forEach((row) =&gt; {
          const originalIndex = itemData.findIndex(r =&gt; r.row === row.row &amp;&amp; r.col === row.col);
          const diff = simpleDiff(row.original, row.revised);
          
          // [신규] 로그 설명 생성
          let logHtml = &quot;&quot;;
          if (row.logs &amp;&amp; row.logs.length &gt; 0) {
            const listItems = row.logs.map(log =&gt; {
               let msg = &quot;&quot;;
               if (log.t === &#39;replace&#39;) {
                 msg = `&#39;&lt;span class=&quot;err-word&quot;&gt;${log.k}&lt;/span&gt;&#39;은(는) 사용할 수 없으므로 추천 대체어인 &#39;&lt;span class=&quot;fix-word&quot;&gt;${log.v}&lt;/span&gt;&#39;(으)로 바꾸었습니다.`;
               } else {
                 msg = `&#39;&lt;span class=&quot;err-word&quot;&gt;${log.k}&lt;/span&gt;&#39;은(는) 생기부 어디에도 기재 불가하므로 삭제해야 합니다.`;
               }
               return `&lt;li class=&quot;analysis-row&quot;&gt;&lt;span&gt;${msg}&lt;/span&gt;&lt;/li&gt;`;
            }).join(&quot;&quot;);
            
            logHtml = `
              &lt;div class=&quot;analysis-box&quot;&gt;
                &lt;ul class=&quot;analysis-list&quot;&gt;${listItems}&lt;/ul&gt;
              &lt;/div&gt;
            `;
          }

          const div = document.createElement(&#39;div&#39;);
          div.className = &#39;check-item&#39;;
          div.innerHTML = `
            &lt;div class=&quot;item-header&quot;&gt;
              &lt;span&gt;행 ${row.row}&lt;/span&gt;
              &lt;span style=&quot;color:#d93025; font-weight:bold;&quot;&gt;금지어 발견&lt;/span&gt;
            &lt;/div&gt;
            &lt;div class=&quot;split-view&quot;&gt;
              &lt;div class=&quot;pane pane-left&quot;&gt;${diff.left}&lt;/div&gt;
              &lt;div class=&quot;pane pane-right&quot; contenteditable=&quot;true&quot; id=&quot;edit-${originalIndex}&quot;&gt;${diff.right}&lt;/div&gt;
            &lt;/div&gt;
            ${logHtml}
          `;
          container.appendChild(div);
        });

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

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

      function applyAll() {
        if (!confirm(&quot;수정된 내용으로 원본을 덮어쓰시겠습니까?&quot;)) return;
        
        const updates = [];
        itemData.forEach((item, index) =&gt; {
          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) =&gt; {
           alert(`${c}건 수정 완료!`);
           google.script.host.close();
        }).applySpellCheckResults(updates);
      }

    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;

&lt;? } ?&gt;
</pre></section></section>]]></description></item><item><title>구글 시트와 Apps Script로 반복 업무 자동화 심화</title><link>https://민기.한국/62</link><guid isPermaLink="true">https://민기.한국/62</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>구글 시트와 Apps Script로 반복 업무 자동화 심화</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-06-26</dd><dt>장소</dt><dd>천안신당고등학교</dd></dl><p>구글 시트와 Apps Script를 활용해 학교 업무 자동화 웹 앱을 설계하고 확장하는 심화 실습 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20260626-cheonan-shindang-high-apps-script-advanced%2Fb042e57976b0bfd07203b535afac32907732e9ebe9e72b599fac987939e00f8d-%5B%EA%B5%90%EC%95%88%5D%20%EA%B5%AC%EA%B8%80%20%EC%8B%9C%ED%8A%B8%EC%99%80%20Apps%20Script%EB%A1%9C%20%EB%B0%98%EB%B3%B5%20%EC%97%85%EB%AC%B4%20%EC%9E%90%EB%8F%99%ED%99%94%20%EC%8B%AC%ED%99%94.pptx?alt=media&amp;token=7d4345dc-3758-4eb4-bdc9-e596024bb2d5">[교안] 구글 시트와 Apps Script로 반복 업무 자동화 심화.pptx</a></li></ul></section>]]></description></item><item><title>과정중심 피드백의 이론과 방법</title><link>https://민기.한국/61</link><guid isPermaLink="true">https://민기.한국/61</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>과정중심 피드백의 이론과 방법</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-06-24</dd><dt>장소</dt><dd>천안동중학교</dd></dl><p>학생의 학습과 성장을 지원하는 과정중심 피드백의 의미와 적용 방법을 다루는 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20260624-cheonan-dong-middle-process-feedback%2Ff769d5c0e9d5ffd6e6aa1e244429d67a64946d4bd954afb978fc5d8ff265bd67-%5B%EA%B5%90%EC%95%88%5D%20%EA%B3%BC%EC%A0%95%EC%A4%91%EC%8B%AC%20%ED%94%BC%EB%93%9C%EB%B0%B1%EC%9D%98%20%EC%9D%B4%EB%A1%A0%EA%B3%BC%20%EB%B0%A9%EB%B2%95.pptx?alt=media&amp;token=a4e71be3-c3f8-45ee-8e97-4c8da8da57ad">[교안] 과정중심 피드백의 이론과 방법.pptx</a></li></ul></section>]]></description></item><item><title>2026년 교장 자격연수 AI·디지털 도구의 활용 실습교육</title><link>https://민기.한국/60</link><guid isPermaLink="true">https://민기.한국/60</guid><pubDate>Sun, 02 Aug 2026 01:32:50 GMT</pubDate><description><![CDATA[<h1>2026년 교장 자격연수 AI·디지털 도구의 활용 실습교육</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-06-23</dd><dt>장소</dt><dd>한국교원대학교</dd></dl>]]></description></item><item><title>구글 시트와 Apps Script로 반복 업무 자동화</title><link>https://민기.한국/59</link><guid isPermaLink="true">https://민기.한국/59</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>구글 시트와 Apps Script로 반복 업무 자동화</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-06-12</dd><dt>장소</dt><dd>충청남도교육청교육과정평가정보원</dd></dl><p>입력·처리·출력 구조를 이해하고 구글 시트와 Apps Script로 학교 업무용 웹 앱을 만드는 실습 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20260612-chungnam-evaluation-institute-apps-script%2Fb042e57976b0bfd07203b535afac32907732e9ebe9e72b599fac987939e00f8d-%5B%EA%B5%90%EC%95%88%5D%20%EA%B5%AC%EA%B8%80%20%EC%8B%9C%ED%8A%B8%EC%99%80%20Apps%20Script%EB%A1%9C%20%EB%B0%98%EB%B3%B5%20%EC%97%85%EB%AC%B4%20%EC%9E%90%EB%8F%99%ED%99%94.pptx?alt=media&amp;token=72e15058-8798-4824-a99e-9a9dac46bef2">[교안] 구글 시트와 Apps Script로 반복 업무 자동화.pptx</a></li></ul></section>]]></description></item><item><title>반복 업무 자동화를 위한 구글 시트와 Apps Script 활용 - 천안신당고등학교</title><link>https://민기.한국/58</link><guid isPermaLink="true">https://민기.한국/58</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>반복 업무 자동화를 위한 구글 시트와 Apps Script 활용 - 천안신당고등학교</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-05-29</dd><dt>장소</dt><dd>천안신당고등학교</dd></dl><p>구글 시트와 Apps Script를 활용해 학교의 반복 업무를 자동화하는 교직원 실습 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20260529-cheonan-shindang-high-apps-script%2F9a06d0923b38c7698fad7920fd9ba6296a580a707c4791b0715bdfc11b16de64-%5B%EA%B0%95%EC%9D%98%EC%9B%90%EA%B3%A0%5D%20%EB%B0%98%EB%B3%B5%20%EC%97%85%EB%AC%B4%20%EC%9E%90%EB%8F%99%ED%99%94%EB%A5%BC%20%EC%9C%84%ED%95%9C%20%EA%B5%AC%EA%B8%80%20%EC%8B%9C%ED%8A%B8%EC%99%80%20Apps%20Script%20%ED%99%9C%EC%9A%A9.pdf?alt=media&amp;token=19d0d8f8-7f08-4f39-b3ca-357281ce9f5d">[강의원고] 반복 업무 자동화를 위한 구글 시트와 Apps Script 활용.pdf</a></li></ul></section>]]></description></item><item><title>반복 업무 자동화를 위한 구글 시트와 Apps Script 활용 - 용남중학교</title><link>https://민기.한국/57</link><guid isPermaLink="true">https://민기.한국/57</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>반복 업무 자동화를 위한 구글 시트와 Apps Script 활용 - 용남중학교</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-05-27</dd><dt>장소</dt><dd>용남중학교</dd></dl><p>구글 시트와 Apps Script를 활용해 학교의 반복 업무를 자동화하는 교직원 실습 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20260527-yongnam-middle-apps-script%2F9a06d0923b38c7698fad7920fd9ba6296a580a707c4791b0715bdfc11b16de64-%5B%EA%B5%90%EC%95%88%5D%20%EB%B0%98%EB%B3%B5%20%EC%97%85%EB%AC%B4%20%EC%9E%90%EB%8F%99%ED%99%94%EB%A5%BC%20%EC%9C%84%ED%95%9C%20%EA%B5%AC%EA%B8%80%20%EC%8B%9C%ED%8A%B8%EC%99%80%20Apps%20Script%20%ED%99%9C%EC%9A%A9.pdf?alt=media&amp;token=09f99631-4cc8-4fc9-9a54-1215a3866e1b">[교안] 반복 업무 자동화를 위한 구글 시트와 Apps Script 활용.pdf</a></li></ul></section>]]></description></item><item><title>데이터 분석 기반 AI 융합 수업 사례</title><link>https://민기.한국/56</link><guid isPermaLink="true">https://민기.한국/56</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>데이터 분석 기반 AI 융합 수업 사례</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-05-24</dd><dt>장소</dt><dd>중앙교육연수원 원격연수</dd></dl><p>의사소통 데이터와 여론 데이터를 활용해 비판적 사고와 문제 해결을 촉진하는 AI 융합수업 사례 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20260524-central-training-institute-ai-data%2Ff6d3ba37ea8648b9eb82d0cced08d586c04f8c576def49581253d82fb614c714-%5B%EA%B5%90%EC%95%88%5D%20%EB%8D%B0%EC%9D%B4%ED%84%B0%20%EB%B6%84%EC%84%9D%20%EA%B8%B0%EB%B0%98%20AI%20%EC%9C%B5%ED%95%A9%20%EC%88%98%EC%97%85%20%EC%82%AC%EB%A1%80.pdf?alt=media&amp;token=8332facd-256c-4b23-9b6d-c8a985e09fa9">[교안] 데이터 분석 기반 AI 융합 수업 사례.pdf</a></li></ul></section>]]></description></item><item><title>반복 업무 자동화를 위한 구글 시트와 Apps Script 활용</title><link>https://민기.한국/55</link><guid isPermaLink="true">https://민기.한국/55</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>반복 업무 자동화를 위한 구글 시트와 Apps Script 활용</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-05-22</dd><dt>장소</dt><dd>충청남도교육청교육과정평가정보원</dd></dl><p>구글 시트와 Apps Script를 활용해 학교의 반복 업무를 구조화하고 자동화하는 실습 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20260522-chungnam-evaluation-institute-apps-script%2F9a06d0923b38c7698fad7920fd9ba6296a580a707c4791b0715bdfc11b16de64-%5B%EA%B5%90%EC%95%88%5D%20%EB%B0%98%EB%B3%B5%20%EC%97%85%EB%AC%B4%20%EC%9E%90%EB%8F%99%ED%99%94%EB%A5%BC%20%EC%9C%84%ED%95%9C%20%EA%B5%AC%EA%B8%80%20%EC%8B%9C%ED%8A%B8%EC%99%80%20Apps%20Script%20%ED%99%9C%EC%9A%A9.pdf?alt=media&amp;token=68f9585a-dee9-4e9e-8bf5-feef06800eb3">[교안] 반복 업무 자동화를 위한 구글 시트와 Apps Script 활용.pdf</a></li></ul></section>]]></description></item><item><title>당진정보고등학교 교직원 연수</title><link>https://민기.한국/54</link><guid isPermaLink="true">https://민기.한국/54</guid><pubDate>Sat, 18 Jul 2026 23:28:33 GMT</pubDate><description><![CDATA[<h1>당진정보고등학교 교직원 연수</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-04-23</dd><dt>장소</dt><dd>당진정보고등학교</dd></dl><p>당진정보고등학교 교직원을 대상으로 진행한 학교 연수 기록입니다.</p>]]></description></item><item><title>국어과 AI 융합교육 현장 사례 공유 - 천안동중 교직원 연수</title><link>https://민기.한국/53</link><guid isPermaLink="true">https://민기.한국/53</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>국어과 AI 융합교육 현장 사례 공유 - 천안동중 교직원 연수</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-04-22</dd><dt>장소</dt><dd>천안동중학교</dd></dl><p>국어과 AI 융합수업의 설계 원리와 실제 수업 사례를 공유하는 교직원 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20260422-cheonan-dong-middle-korean-ai-convergence%2F003a9a8654d1d386cf49320feb1d5cb65660badbd036511aecb6bdb2ecbb05ea-%5B%EA%B0%95%EC%9D%98%EC%9B%90%EA%B3%A0%5D%20%EA%B5%AD%EC%96%B4%EA%B3%BC%20AI%20%EC%9C%B5%ED%95%A9%EA%B5%90%EC%9C%A1%20%ED%98%84%EC%9E%A5%20%EC%82%AC%EB%A1%80%20%EA%B3%B5%EC%9C%A0.pptx?alt=media&amp;token=bfbd1174-450f-4567-a4b2-f48da6dd328e">[강의원고] 국어과 AI 융합교육 현장 사례 공유.pptx</a></li></ul></section>]]></description></item><item><title>국어과 AI 융합교육 현장 사례 공유</title><link>https://민기.한국/52</link><guid isPermaLink="true">https://민기.한국/52</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>국어과 AI 융합교육 현장 사례 공유</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-04-20</dd><dt>장소</dt><dd>서울대학교</dd></dl><p>국어과의 핵심 아이디어와 탐구 방법을 바탕으로 AI 융합수업 현장 사례를 살펴보는 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20260420-seoul-university-korean-ai-convergence%2Fe1f507e80b078108997ba782fbbf346b459fadb834c1c3cbe79a17e4618d4bd3-%5B%EA%B0%95%EC%9D%98%EC%9B%90%EA%B3%A0%5D%20%EA%B5%AD%EC%96%B4%EA%B3%BC%20AI%20%EC%9C%B5%ED%95%A9%EA%B5%90%EC%9C%A1%20%ED%98%84%EC%9E%A5%20%EC%82%AC%EB%A1%80%20%EA%B3%B5%EC%9C%A0.pdf?alt=media&amp;token=507ac5cf-8e41-41d6-a061-09f008b55f8a">[강의원고] 국어과 AI 융합교육 현장 사례 공유.pdf</a></li></ul></section>]]></description></item><item><title>2026 학교관리자 디지털 역량강화 직무연수 2기</title><link>https://민기.한국/51</link><guid isPermaLink="true">https://민기.한국/51</guid><pubDate>Fri, 10 Apr 2026 00:28:58 GMT</pubDate><description><![CDATA[<h1>2026 학교관리자 디지털 역량강화 직무연수 2기</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-04-10</dd><dt>장소</dt><dd>충청남도교육청교육과정평가정보원</dd></dl><p>반복 업무 자동화를 위한 구글 시트와 앱스스크립트 활용 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F2123358_%5B%EA%B9%80%EB%AF%BC%EA%B8%B0%5D_%5B%ED%81%B0_PDF%5D_%EB%B0%98%EB%B3%B5_%EC%97%85%EB%AC%B4_%EC%9E%90%EB%8F%99%ED%99%94%EB%A5%BC_%EC%9C%84%ED%95%9C_%EA%B5%AC%EA%B8%80_%EC%8B%9C%ED%8A%B8%EC%99%80_%EC%95%B1%EC%8A%A4%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8_%ED%99%9C%EC%9A%A9.pdf?alt=media&amp;token=36f0bcf6-1003-4aca-b5c7-4d4b86285389">[김민기] [큰 PDF] 반복 업무 자동화를 위한 구글 시트와 앱스스크립트 활용.pdf</a></li></ul></section><section><h2>관련 링크</h2><ul><li><a href="https://docs.google.com/spreadsheets/d/1JE7gcl1-tLrGQAKP5QUxDXhmrm7Nn749IuhfuTeRVqo/edit?gid=0#gid=0">[1단계] 구글 시트 기본 기능 실습</a></li><li><a href="https://docs.google.com/spreadsheets/d/12-Cp6MkA_vVI2BJpcQw5JTPqDpX1-bbQ9VUZQmwJ21I/edit?gid=190480082#gid=190480082">[2단계] 교보문고 책 정보 추출 자동화 체험</a></li><li><a href="https://docs.google.com/spreadsheets/d/1954WisGxl_LNLYIEOdHB1Z4pmx-zM0WNaZARDGOi4iE/copy">[3단계] 교보문고 책 정보 추출 자동화 실습</a></li><li><a href="https://docs.google.com/spreadsheets/d/1hogJ2pmWbRWKPZTCQN3P7_iuY1EuG8yrCwC_nH4qAko/edit?gid=0#gid=0">[4단계] 앱스스크립트 로그인 구현 체험</a></li><li><a href="https://docs.google.com/spreadsheets/d/1d6cnXbLNpznHtlV8-SAcqqDSZ07LU4d4GTRKfvE654M/copy">[5단계] 앱스스크립트 로그인 구현 실습</a></li></ul></section>]]></description></item><item><title>효과적인 교육과정 평가 환류를 위한 업무 최적화</title><link>https://민기.한국/50</link><guid isPermaLink="true">https://민기.한국/50</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>효과적인 교육과정 평가 환류를 위한 업무 최적화</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-02-19</dd><dt>장소</dt><dd>청양고등학교</dd></dl><p>교육과정 평가 결과를 효과적으로 환류하고 반복 업무를 최적화하는 교직원 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20260219-cheongyang-high-curriculum-feedback%2F70e0124ab01026efc889f728bb04710ad8343d4fae4868c86af9ea6b196093ab-%5B%EA%B5%90%EC%95%88%5D%20%ED%9A%A8%EA%B3%BC%EC%A0%81%EC%9D%B8%20%EA%B5%90%EC%9C%A1%EA%B3%BC%EC%A0%95%20%ED%8F%89%EA%B0%80%20%ED%99%98%EB%A5%98%EB%A5%BC%20%EC%9C%84%ED%95%9C%20%EC%97%85%EB%AC%B4%20%EC%B5%9C%EC%A0%81%ED%99%94.pdf?alt=media&amp;token=da7d37ae-a22a-4d3b-9ddc-3e45d4c30a5d">[교안] 효과적인 교육과정 평가 환류를 위한 업무 최적화.pdf</a></li></ul></section>]]></description></item><item><title>대한민국 교육박람회 AI 디지털 교육자료 세미나</title><link>https://민기.한국/49</link><guid isPermaLink="true">https://민기.한국/49</guid><pubDate>Tue, 13 Jan 2026 21:06:12 GMT</pubDate><description><![CDATA[<h1>대한민국 교육박람회 AI 디지털 교육자료 세미나</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-01-23</dd><dt>장소</dt><dd>서울 코엑스</dd></dl><p>대한민국 교육박람회 중 한국교과서협회가 주관하는 서울 코엑스 AI 디지털 교육자료 교사페스티벌 세미나 강의입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F36121535_%5B%EA%B0%95%EC%9D%98%EC%9B%90%EA%B3%A0%5D%20%EA%B9%80%EB%AF%BC%EA%B8%B0.pptx?alt=media&amp;token=421de4ae-1e16-4d58-8824-978390db5db8">[강의원고] 김민기.pptx</a></li></ul></section>]]></description></item><item><title>AI 융합수업 설계 이론(3)</title><link>https://민기.한국/48</link><guid isPermaLink="true">https://민기.한국/48</guid><pubDate>Sun, 04 Jan 2026 00:38:27 GMT</pubDate><description><![CDATA[<h1>AI 융합수업 설계 이론(3)</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-01-19</dd><dt>장소</dt><dd>서울대학교</dd></dl><p>09:00~13:00 서울대학교 사범대학</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F1767487877969_%5BAIEDAP%5D%20%EC%A0%84%EB%AC%B8%EC%84%B1%20%EA%B0%95%ED%99%94%20%EC%97%B0%EC%88%98(%EC%88%98%EC%97%85%20%EC%84%A4%EA%B3%84%20%EC%8B%AC%ED%99%94%ED%98%95)%20%EA%B5%90%EC%95%88%20%ED%86%B5%ED%95%A9%EB%B3%B8.pdf?alt=media&amp;token=a1394015-f373-4f7d-b945-a44591c4b85c">[AIEDAP] 전문성 강화 연수(수업 설계 심화형) 교안 통합본.pdf</a></li></ul></section><section><h2>관련 링크</h2><ul><li><a href="https://docs.google.com/spreadsheets/d/1jse5ohpdukFrfFy_imD-AgO2FqXA6JK1hm63wOs5-9o/copy">(1) 교수설계 실습 시트</a></li><li><a href="https://docs.google.com/forms/d/e/1FAIpQLSeR6rpUj7aMOMkgI-YFZH6tK0lNtTa8_oyOSNC27aMBvc0BtA/viewform">(2) 교수학습과정안 제출</a></li><li><a href="https://docs.google.com/forms/d/e/1FAIpQLSfQpV0D4OKaHtraKOhF40hBkdSWHOFFNbnkn5Y-Eg3-hpD1Zw/viewform">(3) 만족도 조사</a></li><li><a href="https://docs.google.com/forms/d/e/1FAIpQLSdNxpJIOeooWIktmWljLxT3WJCeELfAnttjUdlFRBk14tqSUQ/viewform">(4) 사후 역량 측정 결과 제출</a></li></ul></section>]]></description></item><item><title>2026 배움기 핵심과정 중등 학급운영 직무연수</title><link>https://민기.한국/47</link><guid isPermaLink="true">https://민기.한국/47</guid><pubDate>Wed, 07 Jan 2026 03:25:14 GMT</pubDate><description><![CDATA[<h1>2026 배움기 핵심과정 중등 학급운영 직무연수</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-01-14</dd><dt>장소</dt><dd>충남교육청교육연수원</dd></dl><p>✅ 안내 <br>🗨️ 담임업무 최적화를 위한 디지털 인공지능도구 활용 분과입니다.<br>🗨️ 바른AI에 로그인할 때 &#39;구글&#39; 로그인이 응답하지 않는다면 &#39;카카오톡&#39; 로그인을 이용해 보세요.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F11857_%5B%EC%95%B1%EC%8A%A4%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%20%EC%BD%94%EB%93%9C%5D%20%EB%B0%94%EB%A5%B8AI%20API%20-%20%EA%B5%AC%EA%B8%80%20%EC%8B%9C%ED%8A%B8%20%EC%97%B0%EB%8F%99.txt?alt=media&amp;token=d483101b-8e06-47de-bbd3-3dce06edec8c">[앱스스크립트 코드] 바른AI API - 구글 시트 연동.txt</a></li><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F3248837_%5B%EA%B9%80%EB%AF%BC%EA%B8%B0%5D%20%EA%B0%95%EC%9D%98%EC%9B%90%EA%B3%A0_%EB%B0%B0%ED%8F%AC%EC%9A%A9.pdf?alt=media&amp;token=d7342ec9-e051-4a3c-ac08-0a2585de7f54">[김민기] 강의원고_배포용.pdf</a></li><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F820_%5B%ED%94%84%EB%A1%AC%ED%94%84%ED%8A%B8%5D%20%EC%A0%9C%EB%AF%B8%EB%82%98%EC%9D%B4%20%EB%82%98%EB%85%B8%EB%B0%94%EB%82%98%EB%82%98%20PRO%EB%A1%9C%20%EC%A6%9D%EB%AA%85%EC%82%AC%EC%A7%84%20%EB%A7%8C%EB%93%A4%EA%B8%B0.txt?alt=media&amp;token=9708c4b3-7774-45c0-96dd-24ceceef73f9">[프롬프트] 제미나이 나노바나나 PRO로 증명사진 만들기.txt</a></li></ul></section><section><h2>관련 링크</h2><ul><li><a href="https://gemini.google.com/app?hl=ko">① [실습] 생성형 AI 제미나이</a></li><li><a href="https://docs.google.com/forms/u/0/">② [실습] 설문조사 도구 구글 폼</a></li><li><a href="https://docs.google.com/spreadsheets/d/1Zw_-1OlsqID_sxsECPdIAlfOfbiPttea7T0ENCkQ524/copy">③ [실습] 구글 설문조사 결과 취합</a></li><li><a href="https://araform.kr/">④ [실습] 설문조사 도구 아라폼</a></li><li><a href="https://script.google.com/macros/s/AKfycbzNGL1aX0dbL_8FAhPkm3065zsgbKU32qXgW7Q4kQHVwpIy5o0GG6Nxm5yGoN4yA_Zl/exec">⑤ [미리보기] 우리반을 위한 학급 경영 도구</a></li><li><a href="https://docs.google.com/spreadsheets/d/1wGSoH6Zpm6_D3Hjg3Qkhh2a5DfxghBPuu7UFI36T0mI/copy">⑥ [실습] 우리반을 위한 학급 경영 도구 만들기</a></li></ul></section>]]></description></item><item><title>공주대학교 AI 융합 수업의 사례 탐구 및 개발 실습 연수</title><link>https://민기.한국/46</link><guid isPermaLink="true">https://민기.한국/46</guid><pubDate>Wed, 07 Jan 2026 07:45:39 GMT</pubDate><description><![CDATA[<h1>공주대학교 AI 융합 수업의 사례 탐구 및 개발 실습 연수</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-01-07</dd><dt>장소</dt><dd>공주대학교</dd></dl><p>공주대학교 연수입니다.</p>]]></description></item><item><title>서울대학교 사범대학 학부 계절학기</title><link>https://민기.한국/45</link><guid isPermaLink="true">https://민기.한국/45</guid><pubDate>Tue, 06 Jan 2026 00:02:30 GMT</pubDate><description><![CDATA[<h1>서울대학교 사범대학 학부 계절학기</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2026-01-06</dd><dt>장소</dt><dd>서울대학교</dd></dl><p>서울대학교 사범대학 학부 계절학기</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F118538_%EC%9D%8C%EC%84%B1%20%EB%85%B9%EC%9D%8C%20%ED%85%8D%EC%8A%A4%ED%8A%B8%20%EC%A0%84%EC%82%AC%EB%B3%B8%20%EC%83%98%ED%94%8C.txt?alt=media&amp;token=f0fd2985-30eb-4f7c-be0d-4d62c8a03893">음성 녹음 텍스트 전사본 샘플.txt</a></li></ul></section><section><h2>관련 링크</h2><ul><li><a href="https://clovanote.naver.com/">[발화 분석 실습] 네이버 클로바노트</a></li><li><a href="https://docs.google.com/spreadsheets/u/0/">[댓글 추출 실습] 구글 스프레드시트</a></li><li><a href="https://cafe.naver.com/minkit/2957">[댓글 추출 실습] 실제 학생 활동</a></li><li><a href="https://gemini.google.com/app?hl=ko">생성형 인공지능 Gemini</a></li></ul></section>]]></description></item><item><title>AI 융합수업 설계 이론(2)</title><link>https://민기.한국/44</link><guid isPermaLink="true">https://민기.한국/44</guid><pubDate>Sun, 04 Jan 2026 00:35:23 GMT</pubDate><description><![CDATA[<h1>AI 융합수업 설계 이론(2)</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-12-28</dd><dt>장소</dt><dd>서울대학교</dd></dl><p>09:00~13:00 서울대학교 사범대학 교육학과</p>]]></description></item><item><title>교무 업무 최적화의 발달 단계</title><link>https://민기.한국/43</link><guid isPermaLink="true">https://민기.한국/43</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>교무 업무 최적화의 발달 단계</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-12-27</dd><dt>장소</dt><dd>공주대학교</dd></dl><p>교무 업무 최적화가 발전하는 단계를 사례 중심으로 살펴보는 대학 발표입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20251227-kongju-university-academic-affairs%2F95a2b9e937552276ab7124548e9cf25ccb866d22b8fb0115b9a548c343d1179a-%5B%EB%B0%9C%ED%91%9C%EC%9E%90%EB%A3%8C%5D%20%EA%B5%90%EB%AC%B4%20%EC%97%85%EB%AC%B4%20%EC%B5%9C%EC%A0%81%ED%99%94%EC%9D%98%20%EB%B0%9C%EB%8B%AC%20%EB%8B%A8%EA%B3%84.pdf?alt=media&amp;token=d2dfe439-ba8e-430a-89a3-f4efbbc75ce3">[발표자료] 교무 업무 최적화의 발달 단계.pdf</a></li></ul></section>]]></description></item><item><title>AI 융합수업 설계 이론(1)</title><link>https://민기.한국/42</link><guid isPermaLink="true">https://민기.한국/42</guid><pubDate>Sun, 04 Jan 2026 00:37:33 GMT</pubDate><description><![CDATA[<h1>AI 융합수업 설계 이론(1)</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-12-21</dd><dt>장소</dt><dd>서울대학교</dd></dl><p>09:00~13:00 서울대학교 사범대학 교육학과</p>]]></description></item><item><title>서산중앙고등학교 교직원 연수</title><link>https://민기.한국/41</link><guid isPermaLink="true">https://민기.한국/41</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>서산중앙고등학교 교직원 연수</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-12-11</dd><dt>장소</dt><dd>서산중앙고등학교</dd></dl><p>서산중앙고등학교 교직원을 대상으로 운영한 학교 연수 기록입니다.</p>]]></description></item><item><title>앱스스크립트와 구글 도구를 활용한 업무 자동화</title><link>https://민기.한국/40</link><guid isPermaLink="true">https://민기.한국/40</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>앱스스크립트와 구글 도구를 활용한 업무 자동화</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-12-06</dd><dt>장소</dt><dd>청양고등학교</dd></dl><p>Apps Script와 구글 도구를 활용해 학교의 반복 업무를 자동화하는 교직원 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20251206-cheongyang-high-apps-script%2F337afd978ea18d708aee1562ecd0a483fd913d1a3c6e929de5ccf13cb055d9d9-%5B%EA%B0%95%EC%9D%98%EC%9B%90%EA%B3%A0%5D%20%EC%95%B1%EC%8A%A4%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EC%99%80%20%EA%B5%AC%EA%B8%80%20%EB%8F%84%EA%B5%AC%EB%A5%BC%20%ED%99%9C%EC%9A%A9%ED%95%9C%20%EC%97%85%EB%AC%B4%20%EC%9E%90%EB%8F%99%ED%99%94.pptx?alt=media&amp;token=62787640-0dd6-4998-b886-97df39b97da6">[강의원고] 앱스스크립트와 구글 도구를 활용한 업무 자동화.pptx</a></li></ul></section>]]></description></item><item><title>구글 클래스룸 기반 수행평가 혁신 가이드 - 행정 효율화와 평가 공정성 확보를 위한 전략</title><link>https://민기.한국/39</link><guid isPermaLink="true">https://민기.한국/39</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>구글 클래스룸 기반 수행평가 혁신 가이드 - 행정 효율화와 평가 공정성 확보를 위한 전략</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-11-24</dd><dt>장소</dt><dd>서산중앙고등학교</dd></dl><p>구글 클래스룸을 활용해 수행평가 행정의 효율성과 평가의 공정성을 높이는 전략을 다루는 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20251124-seosan-jungang-high-google-classroom%2F671677fb1286e86aababb6436a055ec35b77ef7a13101629f8a384214a2261f3-%5B%EA%B0%95%EC%9D%98%EC%9B%90%EA%B3%A0%5D%20%EA%B5%AC%EA%B8%80%20%ED%81%B4%EB%9E%98%EC%8A%A4%EB%A3%B8%20%EA%B8%B0%EB%B0%98%20%EC%88%98%ED%96%89%ED%8F%89%EA%B0%80%20%ED%98%81%EC%8B%A0%20%EA%B0%80%EC%9D%B4%EB%93%9C.pdf?alt=media&amp;token=4abc550c-9e84-4865-870d-373e0e171383">[강의원고] 구글 클래스룸 기반 수행평가 혁신 가이드.pdf</a></li></ul></section>]]></description></item><item><title>AI 시대가 요구하는 인재, 급변하는 대입 제도와 고교학점제로 읽다</title><link>https://민기.한국/38</link><guid isPermaLink="true">https://민기.한국/38</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>AI 시대가 요구하는 인재, 급변하는 대입 제도와 고교학점제로 읽다</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-11-19</dd><dt>장소</dt><dd>구봉중학교</dd></dl><p>학부모를 대상으로 AI 시대의 인재상과 대입 제도, 고교학점제를 함께 살펴보는 연수입니다.</p>]]></description></item><item><title>나무학교 수업 축제 - 업무 최적화</title><link>https://민기.한국/37</link><guid isPermaLink="true">https://민기.한국/37</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>나무학교 수업 축제 - 업무 최적화</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-11-08</dd><dt>장소</dt><dd>나무학교</dd></dl><p>나무학교 수업 축제에서 학교 업무 최적화 아이디어와 실습 사례를 나누는 발표입니다.</p>]]></description></item><item><title>AI 융합교육 사례 공유</title><link>https://민기.한국/36</link><guid isPermaLink="true">https://민기.한국/36</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>AI 융합교육 사례 공유</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-11-04</dd><dt>장소</dt><dd>태안교육지원청</dd></dl><p>학교 현장에서 실천한 AI 융합교육 사례를 공유하는 교원 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20251104-taean-education-office-ai-case%2Fd18965a035b1054e7ac5affeafaa45222491651df252a0920d5e27a7e94e704a-%5B%EA%B0%95%EC%9D%98%EC%9B%90%EA%B3%A0%5D%20AI%20%EC%9C%B5%ED%95%A9%EA%B5%90%EC%9C%A1%20%EC%82%AC%EB%A1%80%20%EA%B3%B5%EC%9C%A0.pdf?alt=media&amp;token=e9b094da-da37-47ad-8a84-648a1829cfa5">[강의원고] AI 융합교육 사례 공유.pdf</a></li></ul></section>]]></description></item><item><title>Gemini API 활용 교직원 연수</title><link>https://민기.한국/35</link><guid isPermaLink="true">https://민기.한국/35</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>Gemini API 활용 교직원 연수</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-11-01</dd><dt>장소</dt><dd>부석중학교</dd></dl><p>Gemini API를 활용한 학교 업무와 수업 지원 방법을 다루는 교직원 연수입니다.</p>]]></description></item><item><title>AI 융합교육 사례 탐색 및 도구 실습</title><link>https://민기.한국/34</link><guid isPermaLink="true">https://민기.한국/34</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>AI 융합교육 사례 탐색 및 도구 실습</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-10-25</dd><dt>장소</dt><dd>서울대학교</dd></dl><p>AI 융합교육 사례를 탐색하고 수업 설계에 필요한 도구를 실습하는 대학 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20251025-seoul-university-ai-course-design%2F71f4f5eca0d0e8f6bd9f7a43d1dd65a974fcbbd3dd3db9843cf396c52f0ee701-%5B%EA%B0%95%EC%9D%98%EC%9B%90%EA%B3%A0%5D%20%EA%B5%AD%EC%96%B4%EA%B3%BC%20AI%20%EC%9C%B5%ED%95%A9%EA%B5%90%EC%9C%A1%20%EC%82%AC%EB%A1%80%20%ED%83%90%EC%83%89%20%EB%B0%8F%20%EB%8F%84%EA%B5%AC%20%EC%8B%A4%EC%8A%B5.pptx?alt=media&amp;token=b3dc1101-107c-49ca-8617-82701ea3f40c">[강의원고] 국어과 AI 융합교육 사례 탐색 및 도구 실습.pptx</a></li></ul></section>]]></description></item><item><title>인공지능 활용 교직원 연수</title><link>https://민기.한국/33</link><guid isPermaLink="true">https://민기.한국/33</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>인공지능 활용 교직원 연수</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-10-14</dd><dt>장소</dt><dd>삽교고등학교</dd></dl><p>학교 현장에서 인공지능을 활용하는 방법을 살펴보는 교직원 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20251014-sapgyo-high-ai-training%2F128a10f1c687b6bd9ae0a8d41814efe9b9b7f069383cfd0d5f3c036c4c0b8318-%5B%EA%B0%95%EC%9D%98%EC%9B%90%EA%B3%A0%5D%20%EC%9D%B8%EA%B3%B5%EC%A7%80%EB%8A%A5%20%ED%99%9C%EC%9A%A9%20%EA%B5%90%EC%A7%81%EC%9B%90%20%EC%97%B0%EC%88%98.pptx?alt=media&amp;token=8ded4c2e-2727-4dd0-9e57-28bfe604cd2e">[강의원고] 인공지능 활용 교직원 연수.pptx</a></li></ul></section>]]></description></item><item><title>교직원 업무 최적화</title><link>https://민기.한국/32</link><guid isPermaLink="true">https://민기.한국/32</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>교직원 업무 최적화</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-09-17</dd><dt>장소</dt><dd>부석중학교</dd></dl><p>디지털 도구를 활용해 교직원의 반복 업무를 줄이고 협업을 개선하는 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20250917-buseok-middle-work-optimization%2F61ebacc5e131128372874ef1d762ad70c77751692068fb7c13b939617cf08c67-%5B%EA%B0%95%EC%9D%98%EC%9B%90%EA%B3%A0%5D%20%EA%B5%90%EC%A7%81%EC%9B%90%20%EC%97%85%EB%AC%B4%20%EC%B5%9C%EC%A0%81%ED%99%94.pptx?alt=media&amp;token=a9a6b95b-2400-4a5e-b1e3-8e5169859386">[강의원고] 교직원 업무 최적화.pptx</a></li></ul></section>]]></description></item><item><title>대전구봉중학교 교직원 연수</title><link>https://민기.한국/31</link><guid isPermaLink="true">https://민기.한국/31</guid><pubDate>Thu, 08 Jan 2026 01:08:31 GMT</pubDate><description><![CDATA[<h1>대전구봉중학교 교직원 연수</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-09-15</dd><dt>장소</dt><dd>대전구봉중학교</dd></dl><p>AI · 디지털 기술을 활용한 학교 업무 최적화 및 반복 업무 경감</p>]]></description></item><item><title>학부모 진로·진학 연수 - 고교 3년 로드맵과 대입 대비 활동 설계</title><link>https://민기.한국/30</link><guid isPermaLink="true">https://민기.한국/30</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>학부모 진로·진학 연수 - 고교 3년 로드맵과 대입 대비 활동 설계</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-09-11</dd><dt>장소</dt><dd>태안고등학교</dd></dl><p>학부모를 대상으로 고교 3년 로드맵과 대입 대비 활동 설계를 안내하는 연수입니다.</p>]]></description></item><item><title>부석중학교 교직원 연수</title><link>https://민기.한국/29</link><guid isPermaLink="true">https://민기.한국/29</guid><pubDate>Wed, 07 Jan 2026 07:42:57 GMT</pubDate><description><![CDATA[<h1>부석중학교 교직원 연수</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-08-20</dd><dt>장소</dt><dd>부석중학교</dd></dl><p>디지털 기반 교육혁신의 필요성</p>]]></description></item><item><title>AIEDAP 마스터 교원 승급 연수</title><link>https://민기.한국/28</link><guid isPermaLink="true">https://민기.한국/28</guid><pubDate>Wed, 07 Jan 2026 07:39:55 GMT</pubDate><description><![CDATA[<h1>AIEDAP 마스터 교원 승급 연수</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-08-02</dd><dt>장소</dt><dd>서울교육대학교</dd></dl><p>교육부-서울대학교 AIEDAP 마스터 교원 승급 연수<br>D.E.E.P 수업모형을 활용한 데이터 기반 인공지능 융합 프로젝트</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2F4229642_%5B%EA%B0%95%EC%9D%98%EC%9B%90%EA%B3%A0%5D%20D.E.E.P%20%EC%88%98%EC%97%85%EB%AA%A8%ED%98%95%EC%9D%84%20%ED%99%9C%EC%9A%A9%ED%95%9C%20%EB%8D%B0%EC%9D%B4%ED%84%B0%20%EA%B8%B0%EB%B0%98%20%EC%9D%B8%EA%B3%B5%EC%A7%80%EB%8A%A5%20%EC%9C%B5%ED%95%A9%20%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8.pdf?alt=media&amp;token=fb3fd6fd-6e55-468c-bb84-c0f8fe94a05e">[강의원고] D.E.E.P 수업모형을 활용한 데이터 기반 인공지능 융합 프로젝트.pdf</a></li></ul></section>]]></description></item><item><title>AI 융합 수업의 사례 탐구 및 개발 실습 - AIEDAP 멘토링</title><link>https://민기.한국/27</link><guid isPermaLink="true">https://민기.한국/27</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>AI 융합 수업의 사례 탐구 및 개발 실습 - AIEDAP 멘토링</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-07-28</dd><dt>장소</dt><dd>목원대학교</dd></dl><p>AIEDAP 멘토링 과정에서 AI 융합 수업 사례를 탐구하고 개발하는 대학 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20250728-mokwon-university-aiedap-mentoring%2F5cc126b9a034b19ac0f59321de02513ed2a7c7a6844c7af1573b48d7de7464ca-%5B%EA%B0%95%EC%9D%98%EC%9B%90%EA%B3%A0%5D%20AI%20%EC%9C%B5%ED%95%A9%20%EC%88%98%EC%97%85%EC%9D%98%20%EC%82%AC%EB%A1%80%20%ED%83%90%EA%B5%AC%20%EB%B0%8F%20%EA%B0%9C%EB%B0%9C%20%EC%8B%A4%EC%8A%B5.pptx?alt=media&amp;token=8744b15c-6e7a-441e-91f0-9f61e1afe7f4">[강의원고] AI 융합 수업의 사례 탐구 및 개발 실습.pptx</a></li></ul></section>]]></description></item><item><title>AI 융합 수업의 사례 탐구 및 개발 실습</title><link>https://민기.한국/26</link><guid isPermaLink="true">https://민기.한국/26</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>AI 융합 수업의 사례 탐구 및 개발 실습</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-07-28</dd><dt>장소</dt><dd>공주대학교</dd></dl><p>AI 융합 수업 사례를 탐구하고 수업을 직접 개발하는 실습 중심 대학 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20250728-kongju-university-ai-convergence%2F5cc126b9a034b19ac0f59321de02513ed2a7c7a6844c7af1573b48d7de7464ca-%5B%EA%B0%95%EC%9D%98%EC%9B%90%EA%B3%A0%5D%20AI%20%EC%9C%B5%ED%95%A9%20%EC%88%98%EC%97%85%EC%9D%98%20%EC%82%AC%EB%A1%80%20%ED%83%90%EA%B5%AC%20%EB%B0%8F%20%EA%B0%9C%EB%B0%9C%20%EC%8B%A4%EC%8A%B5.pptx?alt=media&amp;token=a5887c35-fa1d-4ee4-8ba5-21c8f8c19a66">[강의원고] AI 융합 수업의 사례 탐구 및 개발 실습.pptx</a></li></ul></section>]]></description></item><item><title>빅데이터 활용 인공지능 융합 수업 설계</title><link>https://민기.한국/25</link><guid isPermaLink="true">https://민기.한국/25</guid><pubDate>Sun, 19 Jul 2026 02:52:45 GMT</pubDate><description><![CDATA[<h1>빅데이터 활용 인공지능 융합 수업 설계</h1><dl><dt>강사</dt><dd><a href="/프로필">교사 김민기(민기쌤)</a></dd><dt>일시</dt><dd>2025-07-03</dd><dt>장소</dt><dd>둔포중학교</dd></dl><p>빅데이터를 활용해 인공지능 융합 수업을 설계하는 교직원 연수입니다.</p><section><h2>강의 자료</h2><ul><li><a href="https://firebasestorage.googleapis.com/v0/b/minki-class.firebasestorage.app/o/attachments%2Farchive%2F20250703-dunpo-middle-ai-bigdata%2F4ea206c43a7d337712d1d6abec82a81c86d747e24f4664458c7799556250fb47-%5B%EA%B0%95%EC%9D%98%EC%9B%90%EA%B3%A0%5D%20%EB%B9%85%EB%8D%B0%EC%9D%B4%ED%84%B0%20%ED%99%9C%EC%9A%A9%20%EC%9D%B8%EA%B3%B5%EC%A7%80%EB%8A%A5%20%EC%9C%B5%ED%95%A9%20%EC%88%98%EC%97%85%20%EC%84%A4%EA%B3%84.pptx?alt=media&amp;token=1ed9cccc-67b6-43e6-8648-7bfa6c1844ba">[강의원고] 빅데이터 활용 인공지능 융합 수업 설계.pptx</a></li></ul></section>]]></description></item></channel></rss>
