如何列印完整的 ChatGPT 對話?

ChatGPT 長對話直接列印可能漏掉部分訊息。使用本文提供的瀏覽器 Console 指令碼,可完整列印整段對話或另存為 PDF。

如果按下 Ctrl + P 後只能列印出部分 ChatGPT 對話,問題通常不在印表機,而是頁面沒有同時載入所有訊息。

你可以直接選擇以下兩種方法:

  • 不想安裝擴充功能: 執行本文提供的指令碼,載入完整對話後列印,或另存為 PDF。
  • 經常需要匯出: 使用專門的瀏覽器擴充功能,一鍵把整段對話匯出為 PDF。

為什麼 ChatGPT 只能列印出部分對話?

我檢查了 ChatGPT 的 DOM 結構,發現長對話採用虛擬渲染。捲動頁面時,ChatGPT 會載入目前需要顯示的訊息,也可能移除畫面外的內容。這能避免超長對話拖慢頁面,但瀏覽器只能列印當下仍存在於 DOM 中的部分。

下方的指令碼會自動捲動並收集整段對話,完成後再開啟列印視窗。

方法一:執行指令碼,完整列印對話

如果只需要列印一次,又不想安裝擴充功能,可以使用這個方法。

步驟一:開啟 Chrome Console

先開啟要列印的 ChatGPT 對話,再開啟 Chrome 開發人員工具:

  • Mac: 按下 Command + Option + J
  • Windows 或 Linux: 按下 Ctrl + Shift + J

切換到 Console 分頁,稍後將指令碼貼到圖中標示的空白區域。

Chrome 開發人員工具中標示的 Console 分頁與指令碼貼上區域

開啟 Console 分頁,將指令碼貼到標示的區域。

步驟二:複製並執行指令碼

點擊程式碼區塊右上角的 複製 按鈕,回到 Chrome Console 貼上指令碼,再按下 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();

})();

指令碼內容較長,建議直接使用複製按鈕,不要手動拖曳選取。

只在瀏覽器 Console 執行你信任的程式碼,並確認你有權存取及處理目前的對話。

步驟三:等待列印視窗開啟

指令碼執行期間不要關閉 ChatGPT 分頁。它會自動捲動對話,Console 也會持續顯示目前已收集的訊息數量。對話越長,等待時間越久;掃描完成後,Chrome 會自動開啟列印視窗。

若要印在紙張上,請選擇對應的印表機;若要儲存檔案,請在「目的地」選擇 Save as PDF,再點擊 Save

指令碼收集完 ChatGPT 訊息後開啟的 Chrome 另存為 PDF 視窗

指令碼執行時,Console 會顯示訊息數量;掃描完成後會自動開啟列印視窗。

方法二:使用瀏覽器擴充功能匯出完整對話

如果不想使用開發人員工具,可以透過 AI Exporter  更快完成。開啟 ChatGPT 對話,點擊 Chrome 工具列中的 AI Exporter 圖示,再從彈出視窗選擇 PDF,擴充功能就會載入完整對話並儲存為 PDF。

ChatGPT 對話上方開啟的 AI Exporter 彈出視窗,其中 PDF 匯出選項已標示

點擊擴充功能圖示開啟視窗,再選擇 PDF,即可儲存整段對話。

這個方法需要先安裝瀏覽器擴充功能,但更適合重複匯出,也能直接保留程式碼區塊、表格、圖片和公式。如果不需要整段對話,也可以只選擇其中的重要訊息。

兩種方法該怎麼選?

你的情況較適合的方法
只列印一次,不想安裝任何東西Console 指令碼
熟悉 Chrome 開發人員工具Console 指令碼
經常儲存 ChatGPT 對話瀏覽器擴充功能
需要保留程式碼、表格、公式、圖片或調整 PDF 設定瀏覽器擴充功能

臨時列印一次,可以先用指令碼;如果經常匯出,或更重視 PDF 排版,瀏覽器擴充功能會比較省事。


本文由 Colin 原創撰寫。如需轉載,請在轉載內容中註明本文連結為原始來源。