ChatGPT Conversation Export

Export ChatGPT conversations to Markdown, HTML, or text, with best-effort Deep Research capture and manual paste fallback.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

Advertisement:

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

Advertisement:

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         ChatGPT Conversation Export
// @namespace    https://github.com/Crimsab
// @version      0.1.6
// @description  Export ChatGPT conversations to Markdown, HTML, or text, with best-effort Deep Research capture and manual paste fallback.
// @author       Crimsab
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @match        https://connector_openai_deep_research.web-sandbox.oaiusercontent.com/*
// @match        https://*.web-sandbox.oaiusercontent.com/*
// @include      about:blank
// @include      about:srcdoc
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  "use strict";

  const buttonId = "personal-suite-chatgpt-export";
  const menuId = "personal-suite-chatgpt-export-menu";
  const styleId = "personal-suite-chatgpt-export-style";
  const manualDialogId = "personal-suite-chatgpt-export-manual-deep-research";
  const messageTypes = {
    request: "personal-suite:chatgpt-export:deep-research-request",
    snapshot: "personal-suite:chatgpt-export:deep-research-snapshot"
  };
  const deepResearchTimeoutMs = 900;
  const deepResearchSnapshotCache = new Map();
  let chatGptObserver;
  let remountTimer;

  const exportFormats = [
    { format: "markdown", label: "Markdown (.md)", extension: "md", mime: "text/markdown;charset=utf-8" },
    { format: "html", label: "HTML (.html)", extension: "html", mime: "text/html;charset=utf-8" },
    { format: "text", label: "Text (.txt)", extension: "txt", mime: "text/plain;charset=utf-8" }
  ];

  window.addEventListener("message", handleUserscriptMessage);

  if (isChatGptHost(location.hostname) && window.top === window) {
    mountChatGptExport();
    startChatGptObserver();
  } else {
    startDeepResearchFrameBridge();
  }

  function mountChatGptExport() {
    if (!isChatGptHost(location.hostname)) return;
    injectStyles();

    const container = findHeaderActions();
    if (!container) {
      scheduleChatGptExportRemount();
      return;
    }

    const existing = document.getElementById(buttonId);
    const button = existing || createExportButton();
    if (!document.contains(button)) {
      container.insertBefore(button, container.firstElementChild);
    }
  }

  function createExportButton() {
    const button = document.createElement("button");
    button.id = buttonId;
    button.type = "button";
    button.className = "no-draggable";
    button.setAttribute("aria-label", "Choose chat export format");
    button.title = "Export chat";
    button.innerHTML = [
      '<span class="ps-chatgpt-export-icon" aria-hidden="true">',
      '<svg viewBox="0 0 20 20" focusable="false">',
      '<path d="M10 2.5a.75.75 0 0 1 .75.75v8.2l2.72-2.72a.75.75 0 1 1 1.06 1.06l-4 4a.75.75 0 0 1-1.06 0l-4-4a.75.75 0 1 1 1.06-1.06l2.72 2.72v-8.2A.75.75 0 0 1 10 2.5Z"></path>',
      '<path d="M4.75 14.75a.75.75 0 0 1 .75.75v.75h9v-.75a.75.75 0 0 1 1.5 0V17a.75.75 0 0 1-.75.75H4.75A.75.75 0 0 1 4 17v-1.5a.75.75 0 0 1 .75-.75Z"></path>',
      "</svg>",
      "</span>",
      '<span class="ps-chatgpt-export-label">Export</span>',
      '<span class="ps-chatgpt-export-chevron" aria-hidden="true">',
      '<svg viewBox="0 0 20 20" focusable="false"><path d="M5.72 7.47a.75.75 0 0 1 1.06 0L10 10.69l3.22-3.22a.75.75 0 1 1 1.06 1.06l-3.75 3.75a.75.75 0 0 1-1.06 0L5.72 8.53a.75.75 0 0 1 0-1.06Z"></path></svg>',
      "</span>"
    ].join("");
    button.addEventListener("click", () => openExportMenu(button));
    return button;
  }

  function openExportMenu(anchor) {
    const existing = document.getElementById(menuId);
    if (existing) {
      existing.remove();
      return;
    }

    const menu = document.createElement("div");
    menu.id = menuId;
    menu.setAttribute("role", "menu");
    menu.innerHTML = exportFormats.map((item) => [
      `<button type="button" role="menuitem" data-format="${item.format}">`,
      `<span>${item.label}</span>`,
      `<small>${item.extension.toUpperCase()}</small>`,
      "</button>"
    ].join("")).join("");

    menu.querySelectorAll("button[data-format]").forEach((button) => {
      button.addEventListener("click", (event) => {
        event.stopPropagation();
        const format = button.dataset.format || "markdown";
        menu.remove();
        exportCurrentConversation(anchor, format).catch((error) => {
          console.error("[ChatGPT Conversation Export] export failed", error);
        });
      });
    });

    document.body.append(menu);
    positionExportMenu(menu, anchor);

    let outsidePointerDown;
    const close = () => {
      if (outsidePointerDown) document.removeEventListener("pointerdown", outsidePointerDown, true);
      menu.remove();
    };

    window.addEventListener("resize", close, { once: true });
    window.addEventListener("scroll", close, { once: true, capture: true });
    window.setTimeout(() => {
      outsidePointerDown = (event) => {
        if (event.target instanceof Node && (menu.contains(event.target) || anchor.contains(event.target))) return;
        close();
      };
      document.addEventListener("pointerdown", outsidePointerDown, true);
    });
  }

  function positionExportMenu(menu, anchor) {
    const rect = anchor.getBoundingClientRect();
    const width = 186;
    const margin = 8;
    const left = Math.min(Math.max(margin, rect.left), window.innerWidth - width - margin);
    const top = Math.min(rect.bottom + 8, window.innerHeight - margin);
    menu.style.left = `${Math.round(left)}px`;
    menu.style.top = `${Math.round(top)}px`;
  }

  async function exportCurrentConversation(button, format) {
    const originalLabel = button.querySelector(".ps-chatgpt-export-label")?.textContent || "Export";
    setButtonState(button, "Exporting...");
    try {
      const exportFile = renderExport(await collectExportData({}), format);
      downloadBlob(exportFile.content, exportFile.filename, exportFile.mime);
      setButtonState(button, "Exported");
      window.setTimeout(() => setButtonState(button, originalLabel), 1500);
    } catch (error) {
      setButtonState(button, "Error");
      console.error("[ChatGPT Conversation Export] failed", error);
      window.setTimeout(() => setButtonState(button, originalLabel), 2200);
    }
  }

  function setButtonState(button, label) {
    const labelNode = button.querySelector(".ps-chatgpt-export-label");
    if (labelNode) labelNode.textContent = label;
  }

  async function collectExportData(options = {}) {
    const title = cleanTitle(document.title) || "Conversation with ChatGPT";
    const date = formatLocalDate();
    const turns = await collectTurns(options);
    const deepResearchCount = document.querySelectorAll("iframe").length
      ? findDeepResearchIframes(document).length
      : 0;
    return { title, date, url: location.href, deepResearchCount, turns };
  }

  async function collectTurns(options = {}) {
    const sections = Array.from(document.querySelectorAll("section[data-turn]"));
    if (!sections.length) return collectLegacyMessageTurns();

    const turns = [];
    for (const section of sections) {
      const role = section.dataset.turn
        || section.querySelector("[data-message-author-role]")?.getAttribute("data-message-author-role")
        || "assistant";
      const chunks = [];
      const content = findTurnContent(section, role);
      if (content) chunks.push(elementToMarkdown(content));

      const deepResearch = await collectDeepResearch(section, options);
      chunks.push(...deepResearch);

      const markdown = chunks.map((chunk) => chunk.trim()).filter(Boolean).join("\n\n");
      if (!markdown) continue;

      turns.push({ role, sender: senderForRole(role), markdown });
    }

    return turns;
  }

  function collectLegacyMessageTurns() {
    return Array.from(document.querySelectorAll("[data-message-author-role]")).map((node) => {
      const role = node.getAttribute("data-message-author-role") || "assistant";
      const content = findMessageContent(node, role);
      return {
        role,
        sender: senderForRole(role),
        markdown: content ? elementToMarkdown(content) : ""
      };
    }).filter((turn) => turn.markdown.trim());
  }

  function findTurnContent(section, role) {
    if (role === "user") {
      return section.querySelector("[data-testid='collapsible-user-message-content']")
        || section.querySelector("[data-message-author-role='user'] .whitespace-pre-wrap")
        || section.querySelector("[data-message-author-role='user'] .user-message-bubble-color")
        || section.querySelector("[data-message-author-role='user']");
    }

    const message = section.querySelector("[data-message-author-role='assistant']");
    if (!message) return null;
    return findMessageContent(message, role);
  }

  function findMessageContent(message, role) {
    if (role === "assistant") {
      return message.querySelector(".markdown")
        || message.querySelector("[data-message-content]")
        || message;
    }

    return message.querySelector("[data-testid='collapsible-user-message-content']")
      || message.querySelector(".whitespace-pre-wrap")
      || message.querySelector("[data-message-content]")
      || message;
  }

  async function collectDeepResearch(root, options = {}) {
    const frames = findDeepResearchIframes(root);
    if (!frames.length) return [];

    const requestId = `ps-chatgpt-export-${Date.now()}-${Math.random().toString(36).slice(2)}`;
    for (const frame of frames) {
      try {
        frame.contentWindow?.postMessage({ type: messageTypes.request, requestId }, "*");
      } catch {
        // Cross-origin frame messaging can fail if the frame is still navigating.
      }
    }

    await delay(deepResearchTimeoutMs);
    const snapshots = Array.from(deepResearchSnapshotCache.values())
      .filter((entry) => Date.now() - entry.receivedAt < 60_000)
      .map((entry) => entry.snapshot)
      .filter((snapshot) => snapshot && snapshot.markdown && snapshot.markdown.trim());

    const best = pickBestSnapshot(snapshots);
    if (best) return [renderDeepResearch(best, frames[0], 1)];

    if (!options.skipManualDeepResearchPrompt) {
      const pasted = await requestDeepResearchPaste();
      const pastedSnapshot = buildManualDeepResearchSnapshot(pasted);
      options.skipManualDeepResearchPrompt = true;
      if (pastedSnapshot) return [renderDeepResearch(pastedSnapshot, frames[0], 1)];
    }

    return frames.map((frame, index) => renderDeepResearch(null, frame, index + 1));
  }

  function buildManualDeepResearchSnapshot(value) {
    const markdown = normalizeMarkdown(String(value || "")
      .replace(/\r\n/g, "\n")
      .replace(/\u00a0/g, " ")
      .trim());
    if (!markdown) return null;
    return {
      title: "Deep Research (pasted content)",
      url: "",
      capturedAt: new Date().toISOString(),
      markdown,
      text: markdownToText(markdown)
    };
  }

  function requestDeepResearchPaste() {
    const existing = document.getElementById(manualDialogId);
    if (existing) existing.remove();

    return new Promise((resolve) => {
      const overlay = document.createElement("div");
      overlay.id = manualDialogId;
      overlay.setAttribute("role", "dialog");
      overlay.setAttribute("aria-modal", "false");
      overlay.innerHTML = `
        <div class="ps-chatgpt-export-dialog-card">
          <h2>Paste Deep Research content</h2>
          <p>
            Deep Research is inside a sandboxed frame that a userscript cannot always read.
            This panel does not block the page: use <strong>Copy contents</strong> in the Deep Research panel, paste it here, then continue the export.
          </p>
          <textarea spellcheck="false" placeholder="Paste the copied Deep Research report here"></textarea>
          <div class="ps-chatgpt-export-dialog-actions">
            <button type="button" data-action="skip">Skip Deep Research</button>
            <button type="button" data-action="continue">Continue export</button>
          </div>
        </div>
      `;

      const finish = (value) => {
        overlay.remove();
        resolve(value);
      };

      overlay.querySelector("[data-action='skip']").addEventListener("click", () => finish(""));
      overlay.querySelector("[data-action='continue']").addEventListener("click", () => {
        finish(overlay.querySelector("textarea").value);
      });
      overlay.addEventListener("keydown", (event) => {
        if (event.key === "Escape") finish("");
      });

      document.body.append(overlay);
      overlay.querySelector("textarea").focus();
    });
  }

  function findDeepResearchIframes(root) {
    return Array.from(root.querySelectorAll("iframe")).filter((frame) => {
      const src = frame.src || frame.getAttribute("src") || "";
      const title = frame.title || frame.getAttribute("title") || "";
      return src.includes("deep_research")
        || src.includes("deep-research")
        || src.includes("web-sandbox.oaiusercontent.com")
        || title.includes("deep-research")
        || title.includes("Deep Research");
    });
  }

  function renderDeepResearch(snapshot, frame, index) {
    const title = snapshot?.title?.trim() || `Deep Research ${index}`;
    if (snapshot?.markdown?.trim()) {
      const body = snapshot.markdown.trim();
      const alreadyTitled = new RegExp(`^#{1,6}\\s+${escapeRegExp(title)}(?:\\n|$)`).test(body);
      const lines = alreadyTitled ? [body] : [`#### ${title}`, "", body];
      if (snapshot.url && !isTechnicalFrameUrl(snapshot.url)) lines.push("", `Source frame: ${snapshot.url}`);
      return lines.join("\n");
    }

    const src = frame?.src || frame?.getAttribute?.("src") || "";
    const lines = [
      `#### Deep Research ${index}`,
      "",
      "> Deep Research is embedded in a sandboxed iframe. The userscript detected it, but the iframe content was not readable from this userscript run.",
      "> If ChatGPT shows a 'Copy contents' button for the report, click it and export again. The userscript will include that copied report as a fallback when clipboard access is allowed."
    ];
    if (src) lines.push("", `Frame: ${src}`);
    return lines.join("\n");
  }

  function handleUserscriptMessage(event) {
    const data = event.data;
    if (!data || typeof data !== "object") return;

    if (data.type === messageTypes.snapshot && data.snapshot) {
      if (window.top === window && isChatGptHost(location.hostname)) {
        const key = data.frameKey || data.snapshot.url || `${event.origin}-${Date.now()}`;
        deepResearchSnapshotCache.set(key, { snapshot: data.snapshot, receivedAt: Date.now() });
      } else {
        postToParent(data);
      }
      return;
    }

    if (data.type === messageTypes.request) {
      publishLocalDeepResearchSnapshot(data.requestId || "");
      forwardRequestToChildFrames(data);
    }
  }

  function startDeepResearchFrameBridge() {
    window.setTimeout(() => publishLocalDeepResearchSnapshot("startup"), 300);

    let publishTimer;
    const schedule = () => {
      if (publishTimer) window.clearTimeout(publishTimer);
      publishTimer = window.setTimeout(() => publishLocalDeepResearchSnapshot("mutation"), 500);
    };

    if (document.documentElement) {
      const observer = new MutationObserver(schedule);
      observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
    }
  }

  function publishLocalDeepResearchSnapshot(requestId) {
    const snapshot = collectDeepResearchSnapshot();
    if (!snapshot || !snapshot.markdown.trim()) return;
    postToParent({
      type: messageTypes.snapshot,
      requestId,
      frameKey: `${snapshot.url}:${snapshot.markdown.length}`,
      snapshot
    });
  }

  function collectDeepResearchSnapshot() {
    const root = findDeepResearchContentRoot();
    if (!root) return null;

    const markdown = elementToMarkdown(root).trim();
    const text = visibleText(root);
    if (text.length < 40 && markdown.length < 40) return null;
    if (looksLikeOnlyShell(text)) return null;

    return {
      title: cleanTitle(document.title) || firstHeading(root) || "Deep Research",
      url: location.href,
      capturedAt: new Date().toISOString(),
      markdown,
      text
    };
  }

  function findDeepResearchContentRoot() {
    const candidates = [
      document.querySelector("article"),
      document.querySelector("main article"),
      document.querySelector("main"),
      document.querySelector("[data-testid*='research' i]"),
      document.querySelector("[class*='research' i]"),
      document.body
    ].filter(Boolean);

    return candidates
      .map((node) => ({ node, score: visibleText(node).length }))
      .filter((item) => item.score > 0)
      .sort((a, b) => b.score - a.score)[0]?.node || null;
  }

  function looksLikeOnlyShell(text) {
    const trimmed = text.replace(/\s+/g, " ").trim();
    if (!trimmed) return true;
    if (/^(Copy contents|Copia contenuti|Download|Open|Close|Apri|Chiudi|Loading)$/i.test(trimmed)) return true;
    return trimmed.length < 40;
  }

  function forwardRequestToChildFrames(message) {
    document.querySelectorAll("iframe").forEach((frame) => {
      try {
        frame.contentWindow?.postMessage(message, "*");
      } catch {
        // Ignore frames that are not ready.
      }
    });
  }

  function postToParent(message) {
    try {
      if (window.parent && window.parent !== window) window.parent.postMessage(message, "*");
    } catch {
      // Cross-origin parent may reject direct access in unusual sandbox states.
    }

    try {
      if (window.top && window.top !== window && window.top !== window.parent) window.top.postMessage(message, "*");
    } catch {
      // Best effort only.
    }
  }

  function pickBestSnapshot(snapshots) {
    return snapshots
      .slice()
      .sort((a, b) => (b.markdown?.length || 0) - (a.markdown?.length || 0))[0] || null;
  }

  function renderExport(data, format) {
    const meta = exportFormats.find((item) => item.format === format) || exportFormats[0];
    const filename = buildFilename(meta.extension);
    if (format === "html") return { content: renderHtml(data), filename, mime: meta.mime };
    if (format === "text") return { content: renderText(data), filename, mime: meta.mime };
    return { content: renderMarkdown(data), filename, mime: meta.mime };
  }

  function renderMarkdown(data) {
    const lines = [
      `# ${data.title}`,
      "",
      `**Date:** ${data.date}`,
      `**Source:** [chatgpt.com](${data.url})`,
      data.deepResearchCount ? `**Deep Research frames:** ${data.deepResearchCount}` : "",
      "",
      "---",
      ""
    ].filter((line) => line !== "");

    if (!data.turns.length) lines.push("> No visible conversation turns were found.");
    for (const turn of data.turns) {
      lines.push(`### **${turn.sender}**`, "", turn.markdown.trim(), "", "---", "");
    }
    return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
  }

  function renderHtml(data) {
    const body = data.turns.length
      ? data.turns.map((turn) => [
        `<section class="turn turn-${escapeAttribute(turn.role)}">`,
        `<h2>${escapeHtml(turn.sender)}</h2>`,
        markdownToHtml(turn.markdown),
        "</section>"
      ].join("\n")).join("\n")
      : "<p>No visible conversation turns were found.</p>";

    return `<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>${escapeHtml(data.title)}</title>
    <style>
      :root { color-scheme: light dark; }
      body { max-width: min(1180px, calc(100vw - 48px)); margin: 48px auto; padding: 0 24px; font: 16px/1.62 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
      h1 { line-height: 1.1; }
      h2 { margin-top: 36px; padding-top: 20px; border-top: 1px solid color-mix(in srgb, currentColor 18%, transparent); font-size: 1.05rem; }
      a { color: #2563eb; }
      pre { overflow: auto; padding: 14px; border-radius: 8px; background: #111827; color: #f9fafb; }
      code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
      blockquote { margin-left: 0; padding-left: 14px; border-left: 3px solid color-mix(in srgb, currentColor 24%, transparent); color: color-mix(in srgb, currentColor 72%, transparent); }
      .turn { overflow-x: auto; }
      table { width: max-content; min-width: 100%; max-width: none; margin: 20px 0; border-collapse: collapse; overflow-wrap: normal; }
      th, td { min-width: 9rem; padding: 8px 10px; border: 1px solid color-mix(in srgb, currentColor 18%, transparent); vertical-align: top; overflow-wrap: break-word; word-break: normal; }
      th { background: color-mix(in srgb, currentColor 8%, transparent); text-align: left; font-weight: 650; }
      tr:nth-child(even) td { background: color-mix(in srgb, currentColor 3%, transparent); }
      .meta { color: color-mix(in srgb, currentColor 64%, transparent); }
      .turn-user h2 { text-align: right; }
    </style>
  </head>
  <body>
    <h1>${escapeHtml(data.title)}</h1>
    <p class="meta">${escapeHtml(data.date)} · <a href="${escapeAttribute(data.url)}">${escapeHtml(data.url)}</a>${data.deepResearchCount ? ` · Deep Research frames: ${data.deepResearchCount}` : ""}</p>
    ${body}
  </body>
</html>`;
  }

  function renderText(data) {
    const lines = [
      data.title,
      "",
      `Date: ${data.date}`,
      `Source: ${data.url}`,
      data.deepResearchCount ? `Deep Research frames: ${data.deepResearchCount}` : "",
      "",
      "----",
      ""
    ].filter((line) => line !== "");

    if (!data.turns.length) lines.push("No visible conversation turns were found.");
    for (const turn of data.turns) {
      lines.push(turn.sender, "", markdownToText(turn.markdown), "", "----", "");
    }
    return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
  }

  function markdownToHtml(markdown) {
    const lines = markdown.split(/\r?\n/);
    const chunks = [];
    let index = 0;

    while (index < lines.length) {
      const line = lines[index] || "";
      if (!line.trim()) {
        index += 1;
        continue;
      }

      const fence = line.match(/^```([a-zA-Z0-9_-]*)\s*$/);
      if (fence) {
        const language = fence[1] || "";
        const code = [];
        index += 1;
        while (index < lines.length && !/^```\s*$/.test(lines[index] || "")) {
          code.push(lines[index] || "");
          index += 1;
        }
        if (index < lines.length) index += 1;
        chunks.push(`<pre><code${language ? ` class="language-${escapeAttribute(language)}"` : ""}>${escapeHtml(code.join("\n"))}</code></pre>`);
        continue;
      }

      if (isMarkdownTableStart(lines, index)) {
        const result = consumeMarkdownTable(lines, index);
        chunks.push(result.html);
        index = result.nextIndex;
        continue;
      }

      const heading = line.match(/^(#{1,6})\s+(.+)$/);
      if (heading) {
        const level = Math.min(6, heading[1].length);
        chunks.push(`<h${level}>${inlineMarkdownToHtml(heading[2])}</h${level}>`);
        index += 1;
        continue;
      }

      if (/^---+$/.test(line.trim())) {
        chunks.push("<hr>");
        index += 1;
        continue;
      }

      if (/^\s*[-*]\s+/.test(line)) {
        const items = [];
        while (index < lines.length && /^\s*[-*]\s+/.test(lines[index] || "")) {
          items.push((lines[index] || "").replace(/^\s*[-*]\s+/, ""));
          index += 1;
        }
        chunks.push(`<ul>${items.map((item) => `<li>${inlineMarkdownToHtml(item)}</li>`).join("")}</ul>`);
        continue;
      }

      if (/^\s*\d+\.\s+/.test(line)) {
        const items = [];
        while (index < lines.length && /^\s*\d+\.\s+/.test(lines[index] || "")) {
          items.push((lines[index] || "").replace(/^\s*\d+\.\s+/, ""));
          index += 1;
        }
        chunks.push(`<ol>${items.map((item) => `<li>${inlineMarkdownToHtml(item)}</li>`).join("")}</ol>`);
        continue;
      }

      if (/^>\s?/.test(line)) {
        const quotes = [];
        while (index < lines.length && /^>\s?/.test(lines[index] || "")) {
          quotes.push((lines[index] || "").replace(/^>\s?/, ""));
          index += 1;
        }
        chunks.push(`<blockquote>${quotes.map(inlineMarkdownToHtml).join("<br>")}</blockquote>`);
        continue;
      }

      const paragraph = [line.trim()];
      index += 1;
      while (index < lines.length && lines[index] && lines[index].trim() && !isBlockStart(lines, index)) {
        paragraph.push(lines[index].trim());
        index += 1;
      }
      chunks.push(`<p>${inlineMarkdownToHtml(paragraph.join(" "))}</p>`);
    }

    return chunks.join("\n");
  }

  function isBlockStart(lines, index) {
    const line = lines[index] || "";
    return /^```/.test(line)
      || /^#{1,6}\s+/.test(line)
      || /^---+$/.test(line.trim())
      || /^\s*[-*]\s+/.test(line)
      || /^\s*\d+\.\s+/.test(line)
      || /^>\s?/.test(line)
      || isMarkdownTableStart(lines, index);
  }

  function isMarkdownTableStart(lines, index) {
    const header = lines[index] || "";
    const separator = lines[index + 1] || "";
    return /^\s*\|.*\|\s*$/.test(header)
      && /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(separator);
  }

  function consumeMarkdownTable(lines, index) {
    const header = splitMarkdownTableRow(lines[index] || "");
    index += 2;
    const body = [];
    while (index < lines.length && /^\s*\|.*\|\s*$/.test(lines[index] || "")) {
      body.push(splitMarkdownTableRow(lines[index] || ""));
      index += 1;
    }
    const headerHtml = `<thead><tr>${header.map((cell) => `<th>${inlineMarkdownToHtml(cell)}</th>`).join("")}</tr></thead>`;
    const bodyHtml = body.length
      ? `<tbody>${body.map((row) => `<tr>${row.map((cell) => `<td>${inlineMarkdownToHtml(cell)}</td>`).join("")}</tr>`).join("")}</tbody>`
      : "";
    return { html: `<table>${headerHtml}${bodyHtml}</table>`, nextIndex: index };
  }

  function splitMarkdownTableRow(row) {
    const trimmed = row.trim().replace(/^\|/, "").replace(/\|$/, "");
    const cells = [];
    let current = "";
    let escaped = false;
    for (const char of trimmed) {
      if (escaped) {
        current += char;
        escaped = false;
        continue;
      }
      if (char === "\\") {
        escaped = true;
        continue;
      }
      if (char === "|") {
        cells.push(current.trim());
        current = "";
        continue;
      }
      current += char;
    }
    cells.push(current.trim());
    return cells;
  }

  function inlineMarkdownToHtml(value) {
    let output = escapeHtml(value);
    output = output.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, alt, src) => {
      return `<img alt="${escapeAttribute(unescapeHtml(alt))}" src="${escapeAttribute(unescapeHtml(src))}">`;
    });
    output = output.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, text, href) => {
      const url = safeHref(unescapeHtml(href));
      return url ? `<a href="${escapeAttribute(url)}">${text}</a>` : text;
    });
    output = output.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
    output = output.replace(/\*([^*]+)\*/g, "<em>$1</em>");
    output = output.replace(/`([^`]+)`/g, "<code>$1</code>");
    return output;
  }

  function markdownToText(markdown) {
    return stripChatGptArtifacts(markdown)
      .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "$1 ($2)")
      .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)")
      .replace(/^#{1,6}\s+/gm, "")
      .replace(/^>\s?/gm, "")
      .replace(/```[a-zA-Z0-9_-]*\n?/g, "")
      .replace(/```/g, "")
      .replace(/\*\*([^*]+)\*\*/g, "$1")
      .replace(/\*([^*]+)\*/g, "$1")
      .replace(/`([^`]+)`/g, "$1")
      .trim();
  }

  const blockTags = new Set([
    "ADDRESS", "ARTICLE", "ASIDE", "BLOCKQUOTE", "DD", "DETAILS", "DIV", "DL", "DT",
    "FIELDSET", "FIGCAPTION", "FIGURE", "FOOTER", "FORM", "H1", "H2", "H3", "H4",
    "H5", "H6", "HEADER", "HR", "LI", "MAIN", "NAV", "OL", "P", "PRE", "SECTION",
    "TABLE", "UL"
  ]);

  const noisySelectors = [
    "script",
    "style",
    "noscript",
    "svg",
    ".sr-only",
    "[aria-hidden='true']",
    "[data-testid='copy-turn-action-button']",
    "[data-testid='share-prompt-link-turn-action-button']",
    "[aria-label='Copia']",
    "[aria-label='Copia risposta']",
    "[aria-label='Copia messaggio']",
    "[aria-label='Condividi']",
    "[aria-label='Condividi prompt']",
    "[aria-label='Feedback su Pro']",
    "[aria-label='Più azioni']",
    "[aria-label='Cambia modello']",
    "[aria-label='Modifica messaggio']",
    `#${buttonId}`,
    `#${menuId}`,
    `#${styleId}`,
    `#${manualDialogId}`
  ];

  function elementToMarkdown(root) {
    const clone = root.cloneNode(true);
    pruneNoise(clone);
    return normalizeMarkdown(serializeChildren(clone, { listDepth: 0 }));
  }

  function visibleText(root) {
    return normalizeInlineText(root instanceof HTMLElement && root.innerText ? root.innerText : textWithBreaks(root));
  }

  function pruneNoise(root) {
    root.querySelectorAll(noisySelectors.join(",")).forEach((node) => node.remove());
  }

  function serializeNode(node, context) {
    if (node.nodeType === Node.TEXT_NODE) return normalizeTextNode(node.textContent || "");
    if (!(node instanceof Element)) return "";
    if (isHidden(node)) return "";

    const tag = node.tagName.toUpperCase();
    if (tag === "BR") return "\n";
    if (tag === "HR") return "\n\n---\n\n";
    if (tag === "PRE") return serializeCodeBlock(node);
    if (/^H[1-6]$/.test(tag)) return block(`${"#".repeat(Number(tag.slice(1)))} ${serializeChildren(node, context).trim()}`);
    if (tag === "P") return block(serializeChildren(node, context).trim());
    if (tag === "STRONG" || tag === "B") return wrapInline("**", serializeChildren(node, context));
    if (tag === "EM" || tag === "I") return wrapInline("*", serializeChildren(node, context));
    if (tag === "CODE") return serializeInlineCode(node);
    if (tag === "A") return serializeLink(node, context);
    if (tag === "IMG") return serializeImage(node);
    if (tag === "UL") return serializeList(node, false, context);
    if (tag === "OL") return serializeList(node, true, context);
    if (tag === "BLOCKQUOTE") return serializeBlockquote(node, context);
    if (tag === "TABLE") return serializeTable(node);
    if (tag === "IFRAME") return serializeIframe(node);

    const serialized = serializeChildren(node, context);
    return blockTags.has(tag) ? block(serialized.trim()) : serialized;
  }

  function serializeChildren(node, context) {
    return Array.from(node.childNodes).map((child) => serializeNode(child, context)).join("");
  }

  function serializeCodeBlock(node) {
    const code = node.querySelector("code") || node;
    const className = code.getAttribute("class") || "";
    const language = className.match(/language-([a-zA-Z0-9_-]+)/)?.[1] || "";
    const value = textWithBreaks(code).replace(/\n+$/g, "");
    if (!value.trim()) return "";
    return `\n\n\`\`\`${language}\n${value}\n\`\`\`\n\n`;
  }

  function serializeInlineCode(node) {
    if (node.closest("pre")) return "";
    const value = textWithBreaks(node).trim();
    if (!value) return "";
    const fence = value.includes("`") ? "``" : "`";
    return `${fence}${value}${fence}`;
  }

  function serializeLink(node, context) {
    const href = safeHref(node.getAttribute("href") || "");
    const text = normalizeInlineText(isCitationLink(node) ? visibleText(node) : serializeChildren(node, context)) || href;
    if (!href) return text;
    return `[${escapeLinkText(text)}](${href})`;
  }

  function serializeImage(node) {
    const src = safeHref(node.getAttribute("src") || "");
    const alt = normalizeInlineText(node.getAttribute("alt") || "");
    if (!src) return alt ? `[Image: ${alt}]` : "[Image]";
    return alt ? `![${escapeLinkText(alt)}](${src})` : `![Image](${src})`;
  }

  function serializeList(node, ordered, context) {
    const items = Array.from(node.children).filter((child) => child.tagName.toUpperCase() === "LI");
    const indent = "  ".repeat(context.listDepth);
    const lines = items.map((item, index) => {
      const marker = ordered ? `${index + 1}.` : "-";
      const body = serializeChildren(item, { listDepth: context.listDepth + 1 }).trim();
      return `${indent}${marker} ${body.replace(/\n/g, `\n${indent}  `)}`;
    });
    return `\n\n${lines.join("\n")}\n\n`;
  }

  function serializeBlockquote(node, context) {
    const body = serializeChildren(node, context).trim();
    if (!body) return "";
    return `\n\n${body.split("\n").map((line) => `> ${line}`).join("\n")}\n\n`;
  }

  function serializeTable(node) {
    const rows = Array.from(node.querySelectorAll("tr")).map((row) => {
      return Array.from(row.children)
        .filter((cell) => ["TH", "TD"].includes(cell.tagName.toUpperCase()))
        .map((cell) => normalizeInlineText(elementToMarkdown(cell)).replace(/\|/g, "\\|"));
    }).filter((row) => row.length > 0);

    if (!rows.length) return "";
    const header = rows[0];
    const separator = header.map(() => "---");
    const body = rows.slice(1);
    return `\n\n| ${header.join(" | ")} |\n| ${separator.join(" | ")} |\n${body.map((row) => `| ${row.join(" | ")} |`).join("\n")}\n\n`;
  }

  function serializeIframe(node) {
    const src = node.getAttribute("src") || "";
    const title = node.getAttribute("title") || "Embedded frame";
    if (src.includes("deep_research")
      || src.includes("deep-research")
      || src.includes("web-sandbox.oaiusercontent.com")
      || title.includes("deep-research")
      || title.includes("Deep Research")
      || src === "about:blank"
      || src === "about:srcdoc") {
      return "";
    }
    return src ? `\n\n[Embedded frame: ${title}](${src})\n\n` : "";
  }

  function textWithBreaks(node) {
    if (node.nodeType === Node.TEXT_NODE) return node.textContent || "";
    if (!(node instanceof Element)) return "";
    if (node.tagName.toUpperCase() === "BR") return "\n";
    const body = Array.from(node.childNodes).map(textWithBreaks).join("");
    return blockTags.has(node.tagName.toUpperCase()) && !["SPAN", "CODE"].includes(node.tagName.toUpperCase())
      ? `${body}\n`
      : body;
  }

  function normalizeTextNode(value) {
    return value.replace(/[ \t\r\n]+/g, " ");
  }

  function normalizeInlineText(value) {
    return stripChatGptArtifacts(value).replace(/[ \t\r\n]+/g, " ").trim();
  }

  function normalizeMarkdown(value) {
    return stripChatGptArtifacts(value)
      .replace(/[ \t]+\n/g, "\n")
      .replace(/\n{3,}/g, "\n\n")
      .replace(/^\s+|\s+$/g, "");
  }

  function stripChatGptArtifacts(value) {
    return String(value || "")
      .replace(/[\uE200\uE000]cite[\uE202\uE002][\s\S]*?[\uE201\uE001]/g, "")
      .replace(/cite[\s\S]*?/g, "")
      .replace(/【\s*\d+(?::\d+)?†[^】]*】/g, "")
      .replace(/[ \t]+([.,;:!?])/g, "$1");
  }

  function block(value) {
    return value ? `\n\n${value}\n\n` : "";
  }

  function wrapInline(marker, value) {
    const trimmed = value.trim();
    return trimmed ? `${marker}${trimmed}${marker}` : "";
  }

  function firstHeading(root) {
    return root.querySelector("h1,h2,h3")?.textContent?.trim() || "";
  }

  function isHidden(node) {
    if (node.hasAttribute("hidden")) return true;
    if (node.getAttribute("aria-hidden") === "true") return true;
    if (node instanceof HTMLElement && node.style.display === "none") return true;
    return false;
  }

  function isCitationLink(node) {
    return Boolean(node.closest("[data-testid='webpage-citation-pill']"));
  }

  function safeHref(value) {
    const trimmed = String(value || "").trim();
    if (!trimmed || /^(javascript|data):/i.test(trimmed)) return "";
    try {
      return new URL(trimmed, location.href).toString();
    } catch {
      return trimmed;
    }
  }

  function escapeLinkText(value) {
    return value.replace(/[[\]]/g, "\\$&");
  }

  function findHeaderActions() {
    return document.querySelector("#conversation-header-actions")
      || document.querySelector("[data-testid='thread-header-right-actions']");
  }

  function startChatGptObserver() {
    if (chatGptObserver || !document.documentElement) return;
    chatGptObserver = new MutationObserver(() => scheduleChatGptExportRemount());
    chatGptObserver.observe(document.documentElement, { childList: true, subtree: true });
  }

  function scheduleChatGptExportRemount() {
    if (remountTimer) window.clearTimeout(remountTimer);
    remountTimer = window.setTimeout(() => {
      remountTimer = undefined;
      mountChatGptExport();
    }, 350);
  }

  function downloadBlob(content, filename, mime) {
    const blob = new Blob([content], { type: mime });
    const url = URL.createObjectURL(blob);
    const anchor = document.createElement("a");
    anchor.href = url;
    anchor.download = filename;
    document.body.append(anchor);
    anchor.click();
    anchor.remove();
    window.setTimeout(() => URL.revokeObjectURL(url), 1000);
  }

  function buildFilename(extension) {
    const title = cleanTitle(document.title) || "Conversation";
    const slug = title
      .normalize("NFKD")
      .replace(/[^\w\s-]/g, "")
      .trim()
      .replace(/\s+/g, "-")
      .slice(0, 72) || "Conversation";
    return `ChatGPT_${slug}_${formatLocalDate()}.${extension}`;
  }

  function senderForRole(role) {
    if (role === "user") return "You";
    if (role === "assistant") return "ChatGPT";
    return role || "Unknown";
  }

  function cleanTitle(value) {
    return String(value || "").replace(/\s*[-|]\s*ChatGPT\s*$/i, "").trim();
  }

  function formatLocalDate(date = new Date()) {
    const year = date.getFullYear();
    const month = String(date.getMonth() + 1).padStart(2, "0");
    const day = String(date.getDate()).padStart(2, "0");
    return `${year}-${month}-${day}`;
  }

  function isChatGptHost(host) {
    return host === "chatgpt.com" || host.endsWith(".chatgpt.com") || host === "chat.openai.com";
  }

  function isTechnicalFrameUrl(value) {
    return value.startsWith("about:") || value.startsWith("blob:") || value.startsWith("data:");
  }

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

  function escapeHtml(value) {
    return String(value).replace(/[&<>"']/g, (char) => ({
      "&": "&amp;",
      "<": "&lt;",
      ">": "&gt;",
      "\"": "&quot;",
      "'": "&#39;"
    })[char] || char);
  }

  function unescapeHtml(value) {
    return String(value)
      .replace(/&amp;/g, "&")
      .replace(/&lt;/g, "<")
      .replace(/&gt;/g, ">")
      .replace(/&quot;/g, "\"")
      .replace(/&#39;/g, "'");
  }

  function escapeAttribute(value) {
    return escapeHtml(value).replace(/`/g, "&#96;");
  }

  function delay(ms) {
    return new Promise((resolve) => window.setTimeout(resolve, ms));
  }

  function injectStyles() {
    if (document.getElementById(styleId)) return;
    const style = document.createElement("style");
    style.id = styleId;
    style.textContent = `
      #${buttonId} {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        gap: 6px;
        min-height: 36px;
        padding: 0 10px;
        border: 0;
        border-radius: 10px;
        background: transparent;
        color: currentColor;
        font: inherit;
        font-size: 14px;
        font-weight: 600;
        cursor: pointer;
      }
      #${buttonId}:hover { background: color-mix(in srgb, currentColor 9%, transparent); }
      #${buttonId} svg { width: 20px; height: 20px; fill: currentColor; display: block; }
      #${buttonId} .ps-chatgpt-export-chevron svg { width: 14px; height: 14px; opacity: 0.72; }
      #${menuId} {
        position: fixed;
        z-index: 2147483647;
        width: 186px;
        padding: 6px;
        border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
        border-radius: 12px;
        background: color-mix(in srgb, Canvas 96%, currentColor 4%);
        color: CanvasText;
        box-shadow: 0 18px 45px rgba(0, 0, 0, 0.18);
      }
      #${menuId} button {
        width: 100%;
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 12px;
        padding: 9px 10px;
        border: 0;
        border-radius: 8px;
        background: transparent;
        color: inherit;
        font: 13px/1.2 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        text-align: left;
        cursor: pointer;
      }
      #${menuId} button:hover { background: color-mix(in srgb, currentColor 9%, transparent); }
      #${menuId} small { opacity: 0.58; font-size: 11px; font-weight: 700; }
      #${manualDialogId} {
        position: fixed;
        right: 18px;
        bottom: 18px;
        z-index: 2147483647;
        width: min(720px, calc(100vw - 36px));
        max-height: calc(100vh - 36px);
        pointer-events: none;
      }
      #${manualDialogId} .ps-chatgpt-export-dialog-card {
        pointer-events: auto;
        width: 100%;
        border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
        border-radius: 14px;
        background: color-mix(in srgb, Canvas 98%, currentColor 2%);
        color: CanvasText;
        box-shadow: 0 24px 70px rgba(0, 0, 0, 0.28);
        padding: 18px;
        font: 14px/1.45 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      }
      #${manualDialogId} h2 {
        margin: 0 0 8px;
        font-size: 18px;
        line-height: 1.2;
      }
      #${manualDialogId} p {
        margin: 0 0 14px;
        color: color-mix(in srgb, currentColor 72%, transparent);
      }
      #${manualDialogId} textarea {
        box-sizing: border-box;
        width: 100%;
        min-height: min(280px, calc(100vh - 250px));
        resize: vertical;
        border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
        border-radius: 10px;
        background: color-mix(in srgb, Canvas 94%, currentColor 6%);
        color: CanvasText;
        padding: 12px;
        font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
      }
      #${manualDialogId} .ps-chatgpt-export-dialog-actions {
        display: flex;
        justify-content: flex-end;
        gap: 8px;
        margin-top: 12px;
      }
      #${manualDialogId} button {
        border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
        border-radius: 9px;
        background: color-mix(in srgb, currentColor 8%, transparent);
        color: inherit;
        padding: 8px 12px;
        font: inherit;
        font-weight: 650;
        cursor: pointer;
      }
      #${manualDialogId} button[data-action='continue'] {
        background: #2563eb;
        border-color: #2563eb;
        color: white;
      }
    `;
    document.documentElement.append(style);
  }
})();