ChatGPT GPTs 导出工具

从 ChatGPT 导出你的 GPTs 数据

// ==UserScript==
// @name         ChatGPT GPTs Exporter
// @name:zh-CN   ChatGPT GPTs 导出工具
// @name:zh-TW   ChatGPT GPTs 導出工具
// @namespace    https://github.com/lroolle/chatgpt-degraded
// @version      0.1.1
// @description  Export your GPTs data from ChatGPT
// @description:zh-CN  从 ChatGPT 导出你的 GPTs 数据
// @description:zh-TW  從 ChatGPT 導出你的 GPTs 數據
// @author       lroolle
// @license      AGPL-3.0
// @match        *://chat.openai.com/gpts/*
// @match        *://chatgpt.com/gpts/*
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_setClipboard
// @grant        unsafeWindow
// @run-at       document-start
// @icon         data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDY0IDY0Ij4KICA8ZGVmcz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iZ3JhZGllbnQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPgogICAgICA8c3RvcCBvZmZzZXQ9IjAlIiBzdHlsZT0ic3RvcC1jb2xvcjojMmE5ZDhmO3N0b3Atb3BhY2l0eToxIi8+CiAgICAgIDxzdG9wIG9mZnNldD0iMTAwJSIgc3R5bGU9InN0b3AtY29sb3I6IzJhOWQ4ZjtzdG9wLW9wYWNpdHk6MC44Ii8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogIDwvZGVmcz4KICA8Zz4KICAgIDxjaXJjbGUgY3g9IjMyIiBjeT0iMzIiIHI9IjI4IiBmaWxsPSJ1cmwoI2dyYWRpZW50KSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEiLz4KPCEtLU91dGVyIGNpcmNsZSBtb2RpZmllZCB0byBsb29rIGxpa2UgIkMiLS0+CiAgICA8Y2lyY2xlIGN4PSIzMiIgY3k9IjMyIiByPSIyMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEiIHN0cm9rZS1kYXNoYXJyYXk9IjEyNSA1NSIgc3Ryb2tlLWRhc2hvZmZzZXQ9IjIwIi8+CiAgICA8Y2lyY2xlIGN4PSIzMiIgY3k9IjMyIiByPSIxMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEiLz4KICAgIDxjaXJjbGUgY3g9IjMyIiBjeT0iMzIiIHI9IjQiIGZpbGw9IiNmZmYiLz4KICA8L2c+Cjwvc3ZnPg==
// @homepageURL  https://github.com/lroolle/chatgpt-degraded
// @supportURL   https://github.com/lroolle/chatgpt-degraded/issues
// ==/UserScript==

(function() {
  'use strict';

  // Store GPTs data
  let gptsData = [];
  let isExporting = false;

  // Storage key for persisted data
  const STORAGE_KEY = 'chatgpt_gpts_data';

  // Load persisted data
  function loadPersistedData() {
    try {
      const stored = localStorage.getItem(STORAGE_KEY);
      return stored ? JSON.parse(stored) : [];
    } catch (error) {
      console.error('Error loading persisted GPTs data:', error);
      return [];
    }
  }

  // Save data to persistence
  function persistData(data) {
    try {
      // Create a map of existing data by ID
      const existingData = loadPersistedData();
      const dataMap = new Map(existingData.map(item => [item.id, item]));
      
      // Update or add new data
      data.forEach(item => {
        dataMap.set(item.id, item);
      });
      
      // Convert map back to array and save
      const mergedData = Array.from(dataMap.values());
      localStorage.setItem(STORAGE_KEY, JSON.stringify(mergedData));
      
      return mergedData;
    } catch (error) {
      console.error('Error persisting GPTs data:', error);
      return data;
    }
  }

  // Clear persisted data
  function clearPersistedData() {
    try {
      localStorage.removeItem(STORAGE_KEY);
    } catch (error) {
      console.error('Error clearing persisted GPTs data:', error);
    }
  }

  // i18n support
  const i18n = {
    'en': {
      exportBtn: 'Export GPTs',
      exportJSON: 'Export as JSON',
      exportCSV: 'Export as CSV',
      clearData: 'Clear Data',
      exporting: 'Exporting...',
      copied: 'Copied to clipboard!',
      noData: 'No GPTs data found',
      error: 'Error exporting GPTs',
      dataCleared: 'Data cleared successfully',
      totalGPTs: 'Total GPTs: '
    },
    'zh-CN': {
      exportBtn: '导出 GPTs',
      exportJSON: '导出为 JSON',
      exportCSV: '导出为 CSV',
      clearData: '清除数据',
      exporting: '导出中...',
      copied: '已复制到剪贴板!',
      noData: '未找到 GPTs 数据',
      error: '导出 GPTs 时出错',
      dataCleared: '数据已清除',
      totalGPTs: '总计 GPTs: '
    },
    'zh-TW': {
      exportBtn: '導出 GPTs',
      exportJSON: '導出為 JSON',
      exportCSV: '導出為 CSV',
      clearData: '清除數據',
      exporting: '導出中...',
      copied: '已複製到剪貼板!',
      noData: '未找到 GPTs 數據',
      error: '導出 GPTs 時出錯',
      dataCleared: '數據已清除',
      totalGPTs: '總計 GPTs: '
    }
  };

  // Get user language
  const userLang = (navigator.language || 'en').toLowerCase();
  const lang = i18n[userLang] ? userLang : 
               userLang.startsWith('zh-tw') ? 'zh-TW' :
               userLang.startsWith('zh') ? 'zh-CN' : 'en';
  const t = key => i18n[lang][key] || i18n.en[key];

  // Intercept fetch requests
  const originalFetch = unsafeWindow.fetch;
  unsafeWindow.fetch = async function(resource, options) {
    const response = await originalFetch(resource, options);
    const url = typeof resource === 'string' ? resource : resource?.url;

    // Check if this is the GPTs list request
    if (url && url.includes('/public-api/gizmos/discovery/mine')) {
      try {
        const clonedResponse = response.clone();
        const data = await clonedResponse.json();
        if (data?.list?.items) {
          const newGPTsData = data.list.items.map(item => {
            const gpt = item.resource.gizmo;
            return {
              id: gpt.id,
              name: gpt.display.name || '',
              description: gpt.display.description || '',
              instructions: gpt.instructions || '',
              created_at: gpt.created_at,
              updated_at: gpt.updated_at,
              version: gpt.version,
              tools: item.resource.tools.map(tool => tool.type),
              prompt_starters: gpt.display.prompt_starters || [],
              share_recipient: gpt.share_recipient,
              num_interactions: gpt.num_interactions
            };
          });
          
          // Persist and update the data
          gptsData = persistData(newGPTsData);
          
          // Update the counter in UI
          updateGPTsCounter(gptsData.length);
        }
      } catch (error) {
        console.error('Error intercepting GPTs data:', error);
      }
    }
    return response;
  };

  // Update GPTs counter in UI
  function updateGPTsCounter(count) {
    const counter = document.getElementById('gpts-counter');
    if (counter) {
      counter.textContent = `${t('totalGPTs')}${count}`;
    }
  }

  // Convert GPTs data to CSV
  function convertToCSV(data) {
    const headers = [
      'ID',
      'Name',
      'Description',
      'Instructions',
      'Created At',
      'Updated At',
      'Version',
      'Tools',
      'Prompt Starters',
      'Share Recipient',
      'Interactions'
    ];

    const rows = data.map(gpt => [
      gpt.id,
      `"${(gpt.name || '').replace(/"/g, '""')}"`,
      `"${(gpt.description || '').replace(/"/g, '""')}"`,
      `"${(gpt.instructions || '').replace(/"/g, '""')}"`,
      gpt.created_at,
      gpt.updated_at,
      gpt.version,
      `"${(gpt.tools || []).join(', ')}"`,
      `"${(gpt.prompt_starters || []).join(', ').replace(/"/g, '""')}"`,
      gpt.share_recipient,
      gpt.num_interactions
    ]);

    return [headers.join(','), ...rows.map(row => row.join(','))].join('\n');
  }

  // Export GPTs data
  function exportGPTs(format = 'json') {
    if (isExporting) return;
    isExporting = true;

    try {
      // Load all persisted data
      const allData = loadPersistedData();
      
      if (!allData.length) {
        alert(t('noData'));
        isExporting = false;
        return;
      }

      let exportContent, filename, mimeType;

      if (format === 'csv') {
        exportContent = convertToCSV(allData);
        filename = `chatgpt-gpts-export-${new Date().toISOString().split('T')[0]}.csv`;
        mimeType = 'text/csv';
      } else {
        const exportData = {
          exported_at: new Date().toISOString(),
          total_gpts: allData.length,
          gpts: allData
        };
        exportContent = JSON.stringify(exportData, null, 2);
        filename = `chatgpt-gpts-export-${new Date().toISOString().split('T')[0]}.json`;
        mimeType = 'application/json';
      }

      GM_setClipboard(exportContent);

      // Create and download file
      const blob = new Blob([exportContent], { type: mimeType });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);

      alert(t('copied'));
    } catch (error) {
      console.error('Error exporting GPTs:', error);
      alert(t('error'));
    } finally {
      isExporting = false;
    }
  }

  // Register menu commands
  GM_registerMenuCommand(t('exportJSON'), () => exportGPTs('json'));
  GM_registerMenuCommand(t('exportCSV'), () => exportGPTs('csv'));

  // Add export button to UI
  function addExportButton() {
    const styles = document.createElement('style');
    styles.textContent = `
      .gpts-exporter {
        position: fixed;
        bottom: 20px;
        right: 20px;
        z-index: 10000;
        display: flex;
        flex-direction: column;
        gap: 8px;
        background: var(--surface-primary, rgba(255, 255, 255, 0.9));
        padding: 12px;
        border-radius: 8px;
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
        border: 1px solid var(--border-light, rgba(0, 0, 0, 0.1));
        transition: transform 0.2s ease, opacity 0.2s ease;
      }
      .gpts-exporter:hover {
        transform: translateY(-2px);
      }
      .gpts-exporter.collapsed {
        transform: translateX(calc(100% + 20px));
      }
      .gpts-exporter button {
        padding: 8px 16px;
        background-color: var(--success-color, #10a37f);
        color: #fff;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-size: 14px;
        font-weight: 500;
        transition: all 0.2s ease;
        display: flex;
        align-items: center;
        justify-content: center;
        gap: 6px;
        min-width: 140px;
      }
      .gpts-exporter button:hover {
        opacity: 0.9;
        transform: translateY(-1px);
      }
      .gpts-exporter button:active {
        transform: translateY(0);
      }
      .gpts-exporter .toggle-btn {
        position: absolute;
        left: -32px;
        top: 50%;
        transform: translateY(-50%);
        width: 24px;
        height: 24px;
        background: var(--success-color, #10a37f);
        border: none;
        border-radius: 4px 0 0 4px;
        cursor: pointer;
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 0;
        min-width: unset;
      }
      .gpts-exporter .toggle-btn svg {
        width: 16px;
        height: 16px;
        fill: #fff;
        transition: transform 0.2s ease;
      }
      .gpts-exporter.collapsed .toggle-btn svg {
        transform: rotate(180deg);
      }
      .gpts-exporter button.json-btn {
        background-color: var(--success-color, #10a37f);
      }
      .gpts-exporter button.csv-btn {
        background-color: var(--primary-color, #0ea5e9);
      }
      .gpts-exporter .counter {
        font-size: 12px;
        color: var(--text-secondary, #666);
        text-align: center;
        margin-bottom: 4px;
      }
      .gpts-exporter button.clear-btn {
        background-color: var(--error-color, #dc2626);
        font-size: 12px;
        padding: 4px 8px;
      }
    `;
    document.head.appendChild(styles);

    const container = document.createElement('div');
    container.className = 'gpts-exporter';

    // Add GPTs counter
    const counter = document.createElement('div');
    counter.id = 'gpts-counter';
    counter.className = 'counter';
    counter.textContent = `${t('totalGPTs')}${loadPersistedData().length}`;

    const toggleBtn = document.createElement('button');
    toggleBtn.className = 'toggle-btn';
    toggleBtn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>';
    toggleBtn.addEventListener('click', () => {
      container.classList.toggle('collapsed');
    });

    const jsonBtn = document.createElement('button');
    jsonBtn.className = 'json-btn';
    jsonBtn.innerHTML = `
      <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
        <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>
      ${t('exportJSON')}
    `;
    jsonBtn.addEventListener('click', () => exportGPTs('json'));

    const csvBtn = document.createElement('button');
    csvBtn.className = 'csv-btn';
    csvBtn.innerHTML = `
      <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
        <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
        <polyline points="14 2 14 8 20 8"/>
        <line x1="16" y1="13" x2="8" y2="13"/>
        <line x1="16" y1="17" x2="8" y2="17"/>
        <polyline points="10 9 9 9 8 9"/>
      </svg>
      ${t('exportCSV')}
    `;
    csvBtn.addEventListener('click', () => exportGPTs('csv'));

    // Add clear data button
    const clearBtn = document.createElement('button');
    clearBtn.className = 'clear-btn';
    clearBtn.textContent = t('clearData');
    clearBtn.addEventListener('click', () => {
      if (confirm(t('clearData') + '?')) {
        clearPersistedData();
        gptsData = [];
        updateGPTsCounter(0);
        alert(t('dataCleared'));
      }
    });

    container.appendChild(counter);
    container.appendChild(toggleBtn);
    container.appendChild(jsonBtn);
    container.appendChild(csvBtn);
    container.appendChild(clearBtn);
    document.body.appendChild(container);
  }

  // Initialize
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', addExportButton);
  } else {
    addExportButton();
  }
})();