ChatGPT GPTs 导出工具

从 ChatGPT 导出你的 GPTs 数据

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
  }
})();