ChatGPT 대화 전체를 인쇄하는 방법

긴 ChatGPT 대화를 그대로 인쇄하면 일부 메시지가 빠질 수 있습니다. 브라우저 콘솔 스크립트로 전체 대화를 인쇄하거나 PDF로 저장하는 방법을 알아보세요.

Ctrl + P를 눌렀는데 ChatGPT 대화의 일부만 인쇄된다면 프린터 문제가 아닙니다. 페이지에 모든 메시지가 동시에 로드되어 있지 않기 때문일 수 있습니다.

해결 방법은 두 가지입니다.

  • 확장 프로그램을 설치하고 싶지 않다면: 아래 스크립트를 실행해 전체 대화를 불러온 뒤 인쇄하거나 PDF로 저장합니다.
  • 자주 내보낸다면: 전용 브라우저 확장 프로그램으로 전체 대화를 한 번에 PDF로 저장합니다.

ChatGPT 대화가 일부만 인쇄되는 이유

ChatGPT의 DOM 구조를 확인해 보면 긴 대화에 가상 렌더링이 사용됩니다. 스크롤 위치에 필요한 메시지만 렌더링하고 화면 밖의 내용은 페이지에서 제거할 수 있습니다. 긴 대화에서도 페이지가 느려지지 않게 하는 방식이지만, 브라우저는 그 순간 DOM에 있는 내용만 인쇄할 수 있습니다.

아래 스크립트는 대화를 자동으로 스크롤해 모든 메시지를 수집한 다음 인쇄 창을 엽니다.

방법 1: 스크립트로 전체 대화 인쇄하기

한 번만 인쇄하거나 확장 프로그램을 설치하고 싶지 않을 때 적합합니다.

1단계: Chrome 콘솔 열기

인쇄할 ChatGPT 대화를 연 다음 Chrome 개발자 도구를 실행합니다.

  • Mac: Command + Option + J
  • Windows 또는 Linux: Ctrl + Shift + J

Console 탭을 선택하고 아래 이미지에 표시된 빈 입력 영역에 스크립트를 붙여넣습니다.

Chrome 개발자 도구에서 강조 표시된 Console 탭과 스크립트 입력 영역

Console 탭을 열고 표시된 영역에 스크립트를 붙여넣으세요.

2단계: 스크립트 복사 및 실행

코드 상자 오른쪽 위의 복사를 누른 뒤 Chrome 콘솔에 붙여넣고 Enter를 누릅니다.

javascript
// Load and print a complete ChatGPT conversation.
(async () => {

  /************************************************************
   * CONFIG
   ************************************************************/

  const MESSAGE_SELECTOR = 'section .text-base';

  // 每次向上滚动多少个 viewport
  const STEP_RATIO = 1.2;

  // 每次滚动后的等待时间
  const SCROLL_WAIT = 180;

  // 到顶部以后等待历史消息加载
  const TOP_WAIT = 500;

  // 连续几次顶部无变化后结束
  const TOP_STABLE_LIMIT = 3;

  const sleep = ms =>
    new Promise(resolve => setTimeout(resolve, ms));


  console.log('🚀 AI Chat Print started');


  /************************************************************
   * 1. FIND SCROLL CONTAINER
   ************************************************************/

  function findScrollContainer() {

    const firstMessage =
      document.querySelector(MESSAGE_SELECTOR);

    if (firstMessage) {

      let el =
        firstMessage.parentElement;

      while (
        el &&
        el !== document.body
      ) {

        const style =
          getComputedStyle(el);

        const scrollable =
          el.scrollHeight >
            el.clientHeight + 10 &&
          (
            style.overflowY === 'auto' ||
            style.overflowY === 'scroll'
          );

        if (scrollable) {
          return el;
        }

        el =
          el.parentElement;
      }
    }


    const candidates =
      [...document.querySelectorAll('*')]
        .filter(el => {

          const style =
            getComputedStyle(el);

          return (
            el.scrollHeight >
              el.clientHeight + 10 &&
            (
              style.overflowY === 'auto' ||
              style.overflowY === 'scroll'
            )
          );

        })
        .sort(
          (a, b) =>
            b.scrollHeight -
            a.scrollHeight
        );


    return (
      candidates[0] ||
      document.scrollingElement ||
      document.documentElement
    );
  }


  const scrollContainer =
    findScrollContainer();


  console.log(
    '📜 Scroll container:',
    scrollContainer
  );


  /************************************************************
   * 2. DATA
   ************************************************************/

  const messageMap =
    new Map();

  const edges =
    new Map();

  const firstSeenStep =
    new Map();

  let scanStep = 0;


  /************************************************************
   * 3. HASH
   ************************************************************/

  function hashString(str) {

    let hash =
      2166136261;

    for (
      let i = 0;
      i < str.length;
      i++
    ) {

      hash ^=
        str.charCodeAt(i);

      hash =
        Math.imul(
          hash,
          16777619
        );
    }

    return (
      hash >>> 0
    ).toString(36);
  }


  /************************************************************
   * 4. MESSAGE KEY
   ************************************************************/

  function getMessageKey(node) {

    const section =
      node.closest('section');


    const candidates =
      [
        node,
        section
      ].filter(Boolean);


    const attrs = [
      'data-message-id',
      'data-turn-id',
      'data-id',
      'id'
    ];


    for (
      const el of candidates
    ) {

      for (
        const attr of attrs
      ) {

        const value =
          el.getAttribute?.(attr);

        if (value) {

          return (
            `${attr}:${value}`
          );
        }
      }
    }


    const text =
      node.innerText || '';

    const html =
      node.innerHTML || '';


    return (
      'hash:' +
      hashString(
        text +
        '\n---HTML---\n' +
        html
      )
    );
  }


  /************************************************************
   * 5. COLLECT CURRENT DOM
   ************************************************************/

  function collect() {

    const nodes =
      [
        ...document.querySelectorAll(
          MESSAGE_SELECTOR
        )
      ];


    const keys = [];


    for (
      const node of nodes
    ) {

      const key =
        getMessageKey(node);


      keys.push(key);


      if (
        !messageMap.has(key)
      ) {

        messageMap.set(
          key,
          node.cloneNode(true)
        );


        firstSeenStep.set(
          key,
          scanStep
        );
      }
    }


    /**
     * 保存当前 DOM 中观察到的顺序:
     *
     * A -> B
     * B -> C
     */
    for (
      let i = 0;
      i < keys.length - 1;
      i++
    ) {

      const a =
        keys[i];

      const b =
        keys[i + 1];


      if (
        a === b
      ) {
        continue;
      }


      if (
        !edges.has(a)
      ) {

        edges.set(
          a,
          new Set()
        );
      }


      edges
        .get(a)
        .add(b);
    }


    console.log(
      `📦 step=${scanStep}`,
      `DOM=${nodes.length}`,
      `total=${messageMap.size}`
    );


    return {
      nodes,
      keys
    };
  }


  /************************************************************
   * 6. SCROLL TO BOTTOM FIRST
   ************************************************************/

  async function scrollToBottomFully() {

    console.log(
      '⬇️ Scrolling to bottom...'
    );


    let stable = 0;
    let lastTop = -1;
    let rounds = 0;


    while (
      stable < 3 &&
      rounds < 30
    ) {

      rounds++;


      scrollContainer.scrollTop =
        scrollContainer.scrollHeight;


      await sleep(250);


      const now =
        scrollContainer.scrollTop;


      const max =
        Math.max(
          0,
          scrollContainer.scrollHeight -
          scrollContainer.clientHeight
        );


      if (
        Math.abs(
          now - lastTop
        ) < 2 &&
        Math.abs(
          max - now
        ) < 3
      ) {

        stable++;

      } else {

        stable = 0;
      }


      lastTop =
        now;
    }


    console.log(
      '✅ Bottom reached'
    );
  }


  /************************************************************
   * 7. SCAN FROM BOTTOM TO TOP
   ************************************************************/

  async function scanFromBottomToTop() {

    console.log(
      '⬆️ Scanning upward...'
    );


    let topStable = 0;
    let safety = 0;


    while (
      topStable <
        TOP_STABLE_LIMIT &&
      safety < 10000
    ) {

      safety++;
      scanStep++;


      collect();


      const before =
        scrollContainer.scrollTop;


      /**
       * 还没到顶部
       */
      if (
        before > 1
      ) {

        const distance =
          Math.max(
            300,
            scrollContainer.clientHeight *
              STEP_RATIO
          );


        scrollContainer.scrollTop =
          Math.max(
            0,
            before - distance
          );


        await sleep(
          SCROLL_WAIT
        );


        collect();


        topStable = 0;

        continue;
      }


      /**
       * 已经到顶部
       *
       * 等待网站继续加载更早消息
       */
      const oldHeight =
        scrollContainer.scrollHeight;


      const oldTotal =
        messageMap.size;


      await sleep(
        TOP_WAIT
      );


      scanStep++;

      collect();


      const newHeight =
        scrollContainer.scrollHeight;


      const newTotal =
        messageMap.size;


      if (
        newTotal > oldTotal ||
        newHeight >
          oldHeight + 5 ||
        scrollContainer.scrollTop > 1
      ) {

        topStable = 0;


        console.log(
          '➕ Older messages loaded:',
          newTotal
        );

      } else {

        topStable++;


        console.log(
          `⏳ Top stable ${topStable}/${TOP_STABLE_LIMIT}`
        );
      }
    }


    collect();


    console.log(
      '✅ Scan complete:',
      messageMap.size
    );
  }


  /************************************************************
   * 8. FINAL ORDER
   ************************************************************/

  function getFinalOrder() {

    /**
     * 最优情况:
     *
     * 扫描结束后页面已经把全部消息留在 DOM 中。
     *
     * 这种情况下直接相信当前 DOM 顺序,
     * 不再自己排序。
     */

    const finalNodes =
      [
        ...document.querySelectorAll(
          MESSAGE_SELECTOR
        )
      ];


    const finalKeys =
      finalNodes.map(
        getMessageKey
      );


    const finalKeySet =
      new Set(finalKeys);


    const allCollectedPresent =
      [...messageMap.keys()]
        .every(
          key =>
            finalKeySet.has(key)
        );


    if (
      allCollectedPresent &&
      finalKeys.length
    ) {

      console.log(
        '🎯 Using final DOM order'
      );


      const seen =
        new Set();


      return finalKeys.filter(
        key => {

          if (
            seen.has(key)
          ) {
            return false;
          }

          seen.add(key);

          return true;
        }
      );
    }


    /**
     * fallback:
     *
     * 如果还是严格虚拟滚动,
     * 根据之前记录的局部 DOM 顺序重建。
     */

    console.log(
      '🧩 Rebuilding virtual list order'
    );


    const allKeys =
      [...messageMap.keys()];


    const indegree =
      new Map();


    allKeys.forEach(
      key =>
        indegree.set(
          key,
          0
        )
    );


    edges.forEach(
      (targets, from) => {

        targets.forEach(
          to => {

            if (
              indegree.has(to) &&
              from !== to
            ) {

              indegree.set(
                to,
                indegree.get(to) + 1
              );
            }
          }
        );
      }
    );


    let queue =
      allKeys.filter(
        key =>
          indegree.get(key) === 0
      );


    /**
     * 因为是从底部往顶部扫描,
     * 越晚第一次看到的消息通常越靠前。
     */
    queue.sort(
      (a, b) =>
        (
          firstSeenStep.get(b) || 0
        ) -
        (
          firstSeenStep.get(a) || 0
        )
    );


    const result = [];


    while (
      queue.length
    ) {

      const key =
        queue.shift();


      result.push(key);


      const targets =
        edges.get(key);


      if (
        !targets
      ) {
        continue;
      }


      for (
        const to of targets
      ) {

        if (
          !indegree.has(to)
        ) {
          continue;
        }


        indegree.set(
          to,
          indegree.get(to) - 1
        );


        if (
          indegree.get(to) === 0
        ) {

          queue.push(to);


          queue.sort(
            (a, b) =>
              (
                firstSeenStep.get(b) || 0
              ) -
              (
                firstSeenStep.get(a) || 0
              )
          );
        }
      }
    }


    /**
     * 防止异常关系导致遗漏
     */
    const used =
      new Set(result);


    for (
      const key of allKeys
    ) {

      if (
        !used.has(key)
      ) {

        result.push(key);
      }
    }


    return result;
  }


  /************************************************************
   * 9. START
   ************************************************************/

  await scrollToBottomFully();


  scanStep++;

  collect();


  await scanFromBottomToTop();


  const orderedKeys =
    getFinalOrder();


  console.log(
    '📚 Final messages:',
    orderedKeys.length
  );


  /************************************************************
   * 10. CLEAN OLD PREVIEW
   ************************************************************/

  document
    .querySelector(
      '#ai-export-preview'
    )
    ?.remove();


  document
    .querySelector(
      '#ai-export-print-style'
    )
    ?.remove();


  /************************************************************
   * 11. CREATE PREVIEW
   ************************************************************/

  const preview =
    document.createElement(
      'div'
    );


  preview.id =
    'ai-export-preview';


  preview.innerHTML = `

    <div id="ai-export-toolbar">

      <button id="ai-export-close">
        Close
      </button>

      <button id="ai-export-print">
        Print PDF
      </button>

      <span id="ai-export-count">
      </span>

    </div>

    <div id="ai-export-content">
    </div>

  `;


  preview.style.cssText = `

    position: fixed;

    inset: 0;

    z-index: 2147483647;

    overflow-y: auto;

    overflow-x: hidden;

    background: white;

    padding: 0;

  `;


  /************************************************************
   * 12. TOOLBAR
   ************************************************************/

  const toolbar =
    preview.querySelector(
      '#ai-export-toolbar'
    );


  toolbar.style.cssText = `

    position: sticky;

    top: 0;

    z-index: 9999;

    display: flex;

    align-items: center;

    gap: 10px;

    padding: 12px 20px;

    background: white;

    border-bottom:
      1px solid rgba(0,0,0,.15);

  `;


  toolbar
    .querySelectorAll(
      'button'
    )
    .forEach(
      button => {

        button.style.cssText = `

          padding: 7px 14px;

          cursor: pointer;

          border:
            1px solid #aaa;

          border-radius: 6px;

          background: white;

          color: black;

        `;
      }
    );


  /************************************************************
   * 13. CONTENT
   ************************************************************/

  const content =
    preview.querySelector(
      '#ai-export-content'
    );


  content.style.cssText = `

    width: 100%;

    max-width: 1000px;

    margin: 0 auto;

    padding:
      30px 40px 80px;

    box-sizing:
      border-box;

  `;


  /************************************************************
   * 14. AI EXPORTER BANNER
   *
   * 只在当前页面显示。
   * 打印 PDF 时隐藏。
   ************************************************************/

  const lang =
    (
      navigator.language ||
      'en'
    )
      .replace(
        '_',
        '-'
      );


  const aiExporterUrl =
    'https://chromewebstore.google.com/detail/' +
    'ai-exporter-save-chatgpt/' +
    'kagjkiiecagemklhmhkabbalfpbianbe' +
    `?hl=${encodeURIComponent(lang)}` +
    '&utm_source=chatgptprint';


  const promotion =
    document.createElement(
      'div'
    );


  promotion.id =
    'ai-export-promotion';


  promotion.innerHTML = `

    <div class="ai-export-promo-main">

      <div class="ai-export-promo-badge">
        AI Exporter
      </div>

      <div class="ai-export-promo-text">

        <div class="ai-export-promo-title">
          Export ChatGPT to PDF with AI Exporter
        </div>

        <div class="ai-export-promo-description">
          PDF, Word, Markdown and more.
        </div>

      </div>

      <a
        class="ai-export-promo-button"
        href="${aiExporterUrl}"
        target="_blank"
        rel="noopener noreferrer"
      >
        Get AI Exporter →
      </a>

    </div>

  `;


  promotion.style.cssText = `

    margin:
      0 auto 34px;

    padding:
      18px 20px;

    max-width:
      920px;

    box-sizing:
      border-box;

    border:
      1px solid rgba(59,130,246,.25);

    border-radius:
      14px;

    background:
      linear-gradient(
        135deg,
        #eff6ff 0%,
        #eef2ff 52%,
        #f5f3ff 100%
      );

    color:
      #172554;

    box-shadow:
      0 6px 22px
      rgba(59,130,246,.10);

    font-family:
      -apple-system,
      BlinkMacSystemFont,
      "Segoe UI",
      sans-serif;

  `;


  const promoMain =
    promotion.querySelector(
      '.ai-export-promo-main'
    );


  promoMain.style.cssText = `

    display: flex;

    align-items: center;

    gap: 16px;

  `;


  const badge =
    promotion.querySelector(
      '.ai-export-promo-badge'
    );


  badge.style.cssText = `

    flex:
      0 0 auto;

    padding:
      7px 10px;

    border-radius:
      8px;

    background:
      #2563eb;

    color:
      white;

    font-size:
      13px;

    line-height:
      1;

    font-weight:
      700;

    letter-spacing:
      .2px;

  `;


  const promoText =
    promotion.querySelector(
      '.ai-export-promo-text'
    );


  promoText.style.cssText = `

    flex:
      1 1 auto;

    min-width:
      0;

  `;


  const promoTitle =
    promotion.querySelector(
      '.ai-export-promo-title'
    );


  promoTitle.style.cssText = `

    margin-bottom:
      4px;

    font-size:
      16px;

    line-height:
      1.45;

    font-weight:
      700;

    color:
      #172554;

  `;


  const promoDescription =
    promotion.querySelector(
      '.ai-export-promo-description'
    );


  promoDescription.style.cssText = `

    font-size:
      13px;

    line-height:
      1.5;

    color:
      #475569;

  `;


  const promoButton =
    promotion.querySelector(
      '.ai-export-promo-button'
    );


  promoButton.style.cssText = `

    flex:
      0 0 auto;

    padding:
      9px 13px;

    border-radius:
      8px;

    background:
      #2563eb;

    color:
      white;

    font-size:
      13px;

    line-height:
      1.2;

    font-weight:
      600;

    text-decoration:
      none;

    white-space:
      nowrap;

  `;


  /**
   * 广告放在所有对话最上方
   */
  content.appendChild(
    promotion
  );


  /************************************************************
   * 15. APPEND MESSAGES
   ************************************************************/

  for (
    const key of orderedKeys
  ) {

    const node =
      messageMap.get(key);


    if (
      !node
    ) {
      continue;
    }


    content.appendChild(
      node.cloneNode(true)
    );
  }


  /************************************************************
   * 16. MESSAGE COUNT
   ************************************************************/

  preview.querySelector(
    '#ai-export-count'
  ).textContent =
    `${orderedKeys.length} messages`;


  document.body.appendChild(
    preview
  );


  /************************************************************
   * 17. PRINT CSS
   ************************************************************/

  const printStyle =
    document.createElement(
      'style'
    );


  printStyle.id =
    'ai-export-print-style';


  printStyle.textContent = `

    @media print {

      html,
      body {

        width:
          auto !important;

        height:
          auto !important;

        min-height:
          0 !important;

        max-height:
          none !important;

        overflow:
          visible !important;

      }


      body > *:not(#ai-export-preview) {

        display:
          none !important;

      }


      #ai-export-preview {

        display:
          block !important;

        position:
          static !important;

        inset:
          auto !important;

        width:
          100% !important;

        height:
          auto !important;

        min-height:
          0 !important;

        max-height:
          none !important;

        overflow:
          visible !important;

        padding:
          0 !important;

        margin:
          0 !important;

        background:
          white !important;

      }


      #ai-export-toolbar {

        display:
          none !important;

      }


      /******************************************************
       * PDF 中隐藏 AI Exporter 推荐
       ******************************************************/

      #ai-export-promotion {

        display:
          none !important;

      }


      #ai-export-content {

        display:
          block !important;

        position:
          static !important;

        width:
          100% !important;

        max-width:
          none !important;

        height:
          auto !important;

        min-height:
          0 !important;

        max-height:
          none !important;

        overflow:
          visible !important;

        margin:
          0 !important;

        padding:
          0 !important;

      }


      #ai-export-content > * {

        overflow:
          visible !important;

        max-height:
          none !important;

      }

    }

  `;


  document.head.appendChild(
    printStyle
  );


  /************************************************************
   * 18. CLOSE
   ************************************************************/

  preview.querySelector(
    '#ai-export-close'
  ).onclick = () => {

    preview.remove();

    printStyle.remove();

    console.log(
      '✅ Preview closed'
    );
  };


  /************************************************************
   * 19. PRINT
   ************************************************************/

  function printPDF() {

    console.log(
      '🖨️ Opening print dialog...'
    );


    /**
     * 强制完成 layout
     */
    preview.offsetHeight;


    requestAnimationFrame(
      () => {

        requestAnimationFrame(
          () => {

            window.print();

          }
        );

      }
    );
  }


  preview.querySelector(
    '#ai-export-print'
  ).onclick =
    printPDF;


  console.log(
    '✅ Export preview ready'
  );


  console.log(
    `📄 ${orderedKeys.length} messages`
  );


  /************************************************************
   * 20. AUTO PRINT
   ************************************************************/

  await sleep(500);


  printPDF();

})();

스크립트가 길기 때문에 직접 드래그하지 말고 복사 버튼을 사용하는 것이 안전합니다.

신뢰할 수 있는 코드만 브라우저 콘솔에 붙여넣으세요. 접근 및 처리 권한이 있는 대화에서만 실행해야 합니다.

3단계: 인쇄 창이 열릴 때까지 기다리기

스크립트가 실행되는 동안 ChatGPT 탭을 닫지 마세요. 대화를 자동으로 스크롤하며, 콘솔에는 지금까지 수집한 메시지 수가 표시됩니다. 대화가 길수록 시간이 더 걸립니다. 스캔이 끝나면 Chrome 인쇄 창이 자동으로 열립니다.

종이에 인쇄하려면 프린터를 선택하고, 파일로 저장하려면 대상에서 PDF로 저장을 선택한 뒤 저장을 누릅니다.

ChatGPT 메시지 수집이 끝난 뒤 열린 Chrome PDF 저장 창

실행 중에는 콘솔에 메시지 수가 표시되고, 완료되면 인쇄 창이 자동으로 열립니다.

방법 2: 브라우저 확장 프로그램으로 전체 대화 내보내기

개발자 도구를 사용하고 싶지 않다면 AI Exporter 를 사용할 수 있습니다. ChatGPT 대화를 열고 Chrome 도구 모음에서 AI Exporter 아이콘을 클릭한 다음 팝업에서 PDF를 선택하세요. 확장 프로그램이 전체 대화를 불러와 PDF로 저장합니다.

ChatGPT 위에 열린 AI Exporter 팝업과 강조 표시된 PDF 내보내기 옵션

확장 프로그램 아이콘을 열고 PDF를 선택하면 전체 대화를 저장할 수 있습니다.

확장 프로그램 설치가 필요하지만 반복해서 내보낼 때 더 빠르고, 코드 블록·표·이미지·수식도 별도 설정 없이 보존됩니다. 전체 대화가 필요하지 않다면 원하는 메시지만 선택할 수도 있습니다.

어떤 방법을 선택해야 할까요?

사용 상황추천 방법
한 번만 인쇄하고 아무것도 설치하고 싶지 않음콘솔 스크립트
Chrome 개발자 도구 사용에 익숙함콘솔 스크립트
ChatGPT 대화를 자주 저장함브라우저 확장 프로그램
코드, 표, 수식, 이미지 또는 PDF 설정을 깔끔하게 보존해야 함브라우저 확장 프로그램

한 번만 인쇄한다면 스크립트부터 사용해 보세요. 반복해서 내보내거나 PDF 서식이 중요하다면 확장 프로그램이 더 편리합니다.


이 글은 Colin이 직접 작성한 원문입니다. 재게시할 경우 원문 출처로 이 글의 링크를 표시해 주세요.