Export Amp Thread

Export Amp threads as TXT, JSON, or printable PDF from a floating in-page menu.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Export Amp Thread
// @namespace    https://ampcode.com/
// @version      1.4
// @description  Export Amp threads as TXT, JSON, or printable PDF from a floating in-page menu.
// @author       https://github.com/o-az
// @match        *://ampcode.com/threads/*
// @match        *://*.ampcode.com/threads/*
// @icon         https://ampcode.com/favicon.ico
// @homepageURL  https://github.com/o-az/userscripts
// @source       https://github.com/o-az/userscripts/blob/main/src/export-amp-thread.user.js
// @supportURL   https://github.com/o-az/userscripts/issues
// @tag          amp
// @tag          export
// @tag          thread
// @tag          pdf
// @tag          json
// @license      MIT
// @grant        none
// @run-at       document-idle
// @noframes
// ==/UserScript==

;(() => {
  'use strict'

  const EXPORT_BUTTON_ID = 'export-amp-thread-btn'
  const STYLE_ID = 'export-amp-thread-styles'

  /** @param {string} str */
  function escapeHtml(str) {
    return str
      .replace(/&/g, '&')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#039;')
  }

  function addStyles() {
    if (document.getElementById(STYLE_ID)) return

    const styles = document.createElement('style')
    styles.id = STYLE_ID
    styles.textContent = /* css */ `
      #${EXPORT_BUTTON_ID} {
        position: fixed;
        bottom: 20px;
        right: 20px;
        z-index: 9999;
        background: #d97757;
        color: white;
        border: none;
        border-radius: 8px;
        padding: 10px 16px;
        font-family: system-ui, -apple-system, sans-serif;
        font-size: 14px;
        font-weight: 500;
        cursor: pointer;
        display: flex;
        align-items: center;
        gap: 8px;
        box-shadow: 0 4px 12px rgba(217, 119, 87, 0.4);
        transition: all 0.2s ease;
      }
      #${EXPORT_BUTTON_ID}:hover {
        background: #c46a4e;
        transform: translateY(-2px);
        box-shadow: 0 6px 16px rgba(217, 119, 87, 0.5);
      }
      #${EXPORT_BUTTON_ID}:active {
        transform: translateY(0);
      }
      #${EXPORT_BUTTON_ID} svg {
        width: 16px;
        height: 16px;
      }
      .export-amp-menu {
        position: fixed;
        bottom: 70px;
        right: 20px;
        background: #1a1a1a;
        border: 1px solid #333;
        border-radius: 8px;
        padding: 8px 0;
        min-width: 140px;
        box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
        z-index: 9998;
        display: none;
      }
      .export-amp-menu.visible {
        display: block;
      }
      .export-amp-menu button {
        display: block;
        width: 100%;
        padding: 10px 16px;
        background: transparent;
        border: none;
        color: #e0e0e0;
        font-family: system-ui, -apple-system, sans-serif;
        font-size: 13px;
        text-align: left;
        cursor: pointer;
        transition: background 0.15s;
      }
      .export-amp-menu button:hover {
        background: #333;
        color: white;
      }
    `
    document.head.appendChild(styles)
  }

  const MENU_ID = 'export-amp-menu'

  function createExportMenu() {
    const existing = document.getElementById(MENU_ID)
    if (existing) return existing

    const menu = document.createElement('div')
    Object.assign(menu, {
      id: MENU_ID,
      className: 'export-amp-menu',
    })

    const txtBtn = document.createElement('button')
    txtBtn.textContent = '📄 Export as TXT'
    txtBtn.onclick = () => {
      exportAsText()
      menu.classList.remove('visible')
    }

    const pdfBtn = document.createElement('button')
    pdfBtn.textContent = '📑 Export as PDF'
    pdfBtn.onclick = () => {
      exportAsPDF()
      menu.classList.remove('visible')
    }

    const jsonBtn = document.createElement('button')
    jsonBtn.textContent = '🔧 Export as JSON'
    jsonBtn.onclick = () => {
      exportAsJSON()
      menu.classList.remove('visible')
    }

    menu.appendChild(txtBtn)
    menu.appendChild(pdfBtn)
    menu.appendChild(jsonBtn)
    document.body.appendChild(menu)
    return menu
  }

  function addExportButton() {
    if (document.getElementById(EXPORT_BUTTON_ID)) return

    addStyles()
    const menu = createExportMenu()

    const button = document.createElement('button')
    button.id = EXPORT_BUTTON_ID
    button.innerHTML = /* html */ `
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
        <polyline points="7 10 12 15 17 10"/>
        <line x1="12" y1="15" x2="12" y2="3"/>
      </svg>
      Export Thread
    `

    button.onclick = (event) => {
      event.stopPropagation()
      menu.classList.toggle('visible')
    }

    document.body.appendChild(button)
  }

  // Single global click listener to dismiss the menu — registered once
  document.addEventListener('click', (event) => {
    const target = /** @type {Node|null} */ (event.target)
    const button = document.getElementById(EXPORT_BUTTON_ID)
    const menu = document.getElementById(MENU_ID)
    if (button && menu && !button.contains(target) && !menu.contains(target)) {
      menu.classList.remove('visible')
    }
  })

  /**
   * @typedef {Object} Message
   * @property {string} role
   * @property {string} [type]
   * @property {string} content
   * @property {string} timestamp
   */

  function extractThreadContent() {
    /** @type {Array<Message>} */
    const messages = []
    const processedTexts = new Set()

    const baseData = {
      title: getThreadTitle(),
      url: window.location.href,
      exportedAt: new Date().toISOString(),
      messages,
    }

    // Current Amp thread pages render transcript rows with explicit role markers.
    const transcriptMessages = document.querySelectorAll(
      '[data-transcript-message-role]',
    )
    for (const messageElement of transcriptMessages) {
      const role = messageElement.getAttribute('data-transcript-message-role')
      const content = extractTranscriptMessageContent(messageElement)
      if (!content) continue

      messages.push({
        role: role === 'user' ? 'You' : 'Amp',
        content,
        timestamp: new Date().toISOString(),
      })
    }

    if (messages.length > 0) return baseData

    // Legacy Amp thread pages used a data-thread container with data-block-* blocks.
    const threadContainer = document.querySelector('[data-thread]')
    if (!threadContainer) {
      console.log('Amp Export: No thread container found')
      return baseData
    }

    // Get all message sections in the thread
    // User messages are typically in sections without data-block-id or with specific patterns
    // Assistant messages have data-block-id and data-block-type attributes
    const allSections = threadContainer.querySelectorAll(':scope > div')

    for (const section of allSections) {
      // Check if this is an assistant message (has data-block-id and data-block-type)
      const blockId = section.getAttribute('data-block-id')
      const blockType = section.getAttribute('data-block-type')

      if (blockId && blockType) {
        // This is an assistant message block
        const content = extractBlockContent(section, blockType)
        if (content && !processedTexts.has(content)) {
          processedTexts.add(content)
          messages.push({
            role: 'Amp',
            type: blockType,
            content: content,
            timestamp: new Date().toISOString(),
          })
        }
      } else {
        // This could be a user message - look for text content
        // User messages are typically the content between assistant blocks
        const textContent = extractUserContent(section)
        if (textContent && !processedTexts.has(textContent)) {
          processedTexts.add(textContent)
          messages.push({
            role: 'You',
            content: textContent,
            timestamp: new Date().toISOString(),
          })
        }
      }
    }

    // Alternative: Look for all blocks with data-block-type
    const allBlocks = threadContainer.querySelectorAll(
      '[data-block-id][data-block-type]',
    )
    for (const block of allBlocks) {
      const blockType = block.getAttribute('data-block-type')
      const content = extractBlockContent(block, blockType || '')
      if (content && !processedTexts.has(content)) {
        processedTexts.add(content)
        messages.push({
          role: 'Amp',
          type: blockType || undefined,
          content: content,
          timestamp: new Date().toISOString(),
        })
      }
    }

    return baseData
  }

  function getThreadTitle() {
    const title =
      document
        .querySelector('meta[property="og:title"]')
        ?.getAttribute('content')
        ?.trim() || document.title.replace(/\s+-\s+Amp$/, '').trim()

    return title || 'Amp Thread'
  }

  /**
   * @param {Element} element
   * @returns {string}
   */
  function getElementText(element) {
    const htmlElement = /** @type {HTMLElement} */ (element)
    return (htmlElement.innerText || element.textContent || '').trim()
  }

  /**
   * @param {Element} messageElement
   * @returns {string | undefined}
   */
  function extractTranscriptMessageContent(messageElement) {
    const textRoot =
      messageElement.querySelector('[data-presence-text-root]') ||
      messageElement
    const markdownBlocks = textRoot.querySelectorAll('.markdown')

    if (markdownBlocks.length > 0) {
      return [...markdownBlocks]
        .map((block) => getElementText(block))
        .filter(Boolean)
        .join('\n\n')
    }

    return getElementText(textRoot)
  }

  /**
   * @param {Element} block
   * @param {string} blockType
   * @returns {string | undefined}
   */
  function extractBlockContent(block, blockType) {
    // For text blocks, get the markdown content
    const markdownDiv = /** @type {HTMLElement | null} */ (
      block.querySelector('.markdown')
    )
    if (markdownDiv) {
      return getElementText(markdownDiv)
    }

    // For tool_use blocks, get the tool information
    if (blockType === 'tool_use') {
      const resourceChip = /** @type {HTMLElement | null} */ (
        block.querySelector('.resource-chip')
      )
      if (resourceChip) {
        const toolName = resourceChip.querySelector('a')?.textContent?.trim()
        const toolContent = resourceChip.innerText?.trim()
        return toolName ? `[Tool: ${toolName}]\n${toolContent}` : toolContent
      }
    }

    // For thinking blocks
    if (blockType === 'thinking') {
      const thinkingContent =
        block.querySelector('[data-thinking]')?.textContent?.trim() ||
        getElementText(block)
      return `[Thinking]\n${thinkingContent}`
    }

    // Default: get all text content
    return getElementText(block)
  }

  /** @type {(section: HTMLElement) => string | null} */
  function extractUserContent(section) {
    // Skip if it has block attributes (assistant content)
    if (section.hasAttribute('data-block-id')) return null

    // Get text content but filter out UI elements
    const text = getElementText(section)
    if (!text) return null

    // Filter out common UI text patterns
    const uiPatterns = [
      'Copy',
      'Link to this block',
      'Workspace',
      'Archived',
      'Run',
      'Accept',
      'Reject',
      'Edit',
    ]

    for (const pattern of uiPatterns) {
      if (text === pattern || text.startsWith(pattern + '\n')) return null
    }

    // Check if it's just UI elements
    if (text.length < 3) return null

    return text
  }

  /**
   * @param {{title: string, url: string, exportedAt: string, messages: Array<{role: string, type?: string, content: string}>}} data
   */
  function formatAsText(data) {
    let output = `${data.title}\n`
    output += `${'='.repeat(data.title.length)}\n\n`
    output += `URL: ${data.url}\n`
    output += `Exported: ${new Date(data.exportedAt).toLocaleString()}\n\n`
    output += `${'-'.repeat(50)}\n\n`

    for (const msg of data.messages) {
      const roleLabel = msg.type ? `${msg.role} (${msg.type})` : msg.role
      output += `[${roleLabel}]\n`
      output += `${msg.content}\n\n`
    }

    return output
  }

  function exportAsText() {
    const data = extractThreadContent()
    const text = formatAsText(data)
    const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
    const url = URL.createObjectURL(blob)
    const fileBaseName = getExportFileBaseName(data.title)

    const a = document.createElement('a')
    Object.assign(a, {
      href: url,
      download: `${fileBaseName}.txt`,
    })
    document.body.appendChild(a)
    a.click()
    document.body.removeChild(a)
    URL.revokeObjectURL(url)
  }

  function exportAsJSON() {
    const data = extractThreadContent()
    const json = JSON.stringify(data, null, 2)
    const blob = new Blob([json], { type: 'application/json;charset=utf-8' })
    const url = URL.createObjectURL(blob)
    const fileBaseName = getExportFileBaseName(data.title)

    const a = document.createElement('a')
    Object.assign(a, {
      href: url,
      download: `${fileBaseName}.json`,
    })
    document.body.appendChild(a)
    a.click()
    document.body.removeChild(a)
    URL.revokeObjectURL(url)
  }

  /** @param {string} title */
  function getExportFileBaseName(title) {
    try {
      const normalizedTitle = title
        .trim()
        .toLowerCase()
        .replace(/\s+/g, '-')
        .replace(/[^a-z0-9-_]/g, '')
        .replace(/-+/g, '-')
        .replace(/^-|-$/g, '')

      if (!normalizedTitle) {
        const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
        return `amp-thread-${timestamp}`
      }

      return normalizedTitle.startsWith('amp-')
        ? normalizedTitle
        : `amp-${normalizedTitle}`
    } catch {
      const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
      return `amp-thread-${timestamp}`
    }
  }

  function exportAsPDF() {
    const data = extractThreadContent()
    const fileBaseName = getExportFileBaseName(data.title)

    // Create a printable window
    const printWindow = window.open('', '_blank')
    if (!printWindow) {
      alert('Please allow popups to export as PDF')
      return
    }

    printWindow.document.write(/* html */ `
      <!DOCTYPE html>
      <html>
      <head>
        <title>${escapeHtml(fileBaseName)}</title>
        <style>
          body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            line-height: 1.6;
            max-width: 800px;
            margin: 40px auto;
            padding: 20px;
            color: #333;
          }
          h1 {
            border-bottom: 2px solid #d97757;
            padding-bottom: 10px;
            margin-bottom: 20px;
          }
          .meta {
            color: #666;
            font-size: 14px;
            margin-bottom: 30px;
          }
          .message {
            margin: 20px 0;
            padding: 15px;
            background: #f8f8f8;
            border-radius: 8px;
            border-left: 4px solid #d97757;
          }
          .message.user {
            border-left-color: #4a90d9;
            background: #f0f7ff;
          }
          .role {
            font-weight: 600;
            color: #d97757;
            margin-bottom: 8px;
            font-size: 14px;
            text-transform: uppercase;
          }
          .message.user .role {
            color: #4a90d9;
          }
          .content {
            white-space: pre-wrap;
          }
          @media print {
            body { margin: 0; }
            .no-print { display: none; }
          }
        </style>
      </head>
      <body>
        <button class="no-print" onclick="window.print()" style="
          position: fixed;
          top: 20px;
          right: 20px;
          padding: 10px 20px;
          background: #d97757;
          color: white;
          border: none;
          border-radius: 6px;
          cursor: pointer;
          font-size: 14px;
        ">Print / Save as PDF</button>
        <h1>${escapeHtml(data.title)}</h1>
        <div class="meta">
          <strong>URL:</strong> ${escapeHtml(data.url)}<br>
          <strong>Exported:</strong> ${new Date(data.exportedAt).toLocaleString()}
        </div>
        <hr>
        ${data.messages
          .map(
            (msg) => /* html */ `
          <div class="message ${msg.role === 'You' ? 'user' : ''}">
            <div class="role">${escapeHtml(msg.role)}${msg.type ? ` (${escapeHtml(msg.type)})` : ''}</div>
            <div class="content">${escapeHtml(msg.content)}</div>
          </div>
        `,
          )
          .join('')}
      </body>
      </html>
    `)
    printWindow.document.close()
  }

  // Initialize
  function init() {
    // Wait for the thread to load
    const checkInterval = setInterval(() => {
      // Look for thread container or messages
      const hasThread =
        document.querySelector('[data-transcript-message-role]') ||
        document.querySelector('[data-thread]') ||
        document.querySelector('[data-block-id]') ||
        document.querySelector('.markdown')

      if (hasThread) {
        clearInterval(checkInterval)
        addExportButton()
      }
    }, 1_000)

    // Stop checking after 30 seconds
    setTimeout(() => clearInterval(checkInterval), 30_000)
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init)
  } else {
    init()
  }

  // Re-add button if removed by page navigation (SPA)
  const observer = new MutationObserver(() => {
    if (document.getElementById(EXPORT_BUTTON_ID)) return

    const hasThread =
      document.querySelector('[data-transcript-message-role]') ||
      document.querySelector('[data-thread]') ||
      document.querySelector('[data-block-id]') ||
      document.querySelector('.markdown')

    if (hasThread) {
      addExportButton()
    }
  })
  observer.observe(document.body, { childList: true, subtree: true })
})()