网页文章总结助手

自动总结网页文章内容,支持多种格式输出,适用于各类文章网站

目前为 2025-03-14 提交的版本,查看 最新版本

// ==UserScript==
// @name         网页文章总结助手
// @namespace    http://tampermonkey.net/
// @version      0.2.0
// @description  自动总结网页文章内容,支持多种格式输出,适用于各类文章网站
// @author       h7ml <[email protected]>
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @connect      api.gptgod.online
// @connect      api.deepseek.com
// @resource     marked https://cdn.bootcdn.net/ajax/libs/marked/4.3.0/marked.min.js
// @resource     highlight https://cdn.bootcdn.net/ajax/libs/highlight.js/11.7.0/highlight.min.js
// @resource     highlightStyle https://cdn.bootcdn.net/ajax/libs/highlight.js/11.7.0/styles/github.min.css
// ==/UserScript==

(function () {
  'use strict';

  // 配置管理类
  class ConfigManager {
    constructor() {
      this.DEFAULT_API_SERVICE = 'ollama';
      this.DEFAULT_CONFIGS = {
        ollama: {
          url: 'http://localhost:11434/api/chat',
          model: 'llama2',
          key: ''  // Ollama 不需要 API key
        },
        gptgod: {
          url: 'https://api.gptgod.online/v1/chat/completions',
          model: 'gpt-4o-all',
          key: 'sk-L1rbJXBp3aDrZLgyrUq8FugKU54FxElTbzt7RfnBaWgHOtFj'
        },
        deepseek: {
          url: 'https://api.deepseek.com/v1/chat/completions',
          model: 'deepseek-chat',
          key: ''
        },
        custom: {
          url: '',
          model: '',
          key: ''
        }
      };
      this.DEFAULT_FORMAT = 'markdown';
      this.OLLAMA_MODELS = [
        'llama2',
        'llama2:13b',
        'llama2:70b',
        'mistral',
        'mixtral',
        'gemma:2b',
        'gemma:7b',
        'qwen:14b',
        'qwen:72b',
        'phi3:mini',
        'phi3:small',
        'phi3:medium',
        'yi:34b',
        'vicuna:13b',
        'vicuna:33b',
        'codellama',
        'wizardcoder',
        'nous-hermes2',
        'neural-chat',
        'openchat',
        'dolphin-mixtral',
        'starling-lm'
      ];
    }

    getConfigs() {
      return GM_getValue('apiConfigs', this.DEFAULT_CONFIGS);
    }

    getApiService() {
      return GM_getValue('apiService', this.DEFAULT_API_SERVICE);
    }

    getOutputFormat() {
      return GM_getValue('outputFormat', this.DEFAULT_FORMAT);
    }

    getConfigCollapsed() {
      return GM_getValue('configCollapsed', false);
    }

    getAppMinimized() {
      return GM_getValue('appMinimized', false);
    }

    getAppPosition() {
      return GM_getValue('appPosition', null);
    }

    getIconPosition() {
      return GM_getValue('iconPosition', null);
    }

    setConfigs(configs) {
      GM_setValue('apiConfigs', configs);
    }

    setApiService(service) {
      GM_setValue('apiService', service);
    }

    setOutputFormat(format) {
      GM_setValue('outputFormat', format);
    }

    setConfigCollapsed(collapsed) {
      GM_setValue('configCollapsed', collapsed);
    }

    setAppMinimized(minimized) {
      GM_setValue('appMinimized', minimized);
    }

    setAppPosition(position) {
      GM_setValue('appPosition', position);
    }

    setIconPosition(position) {
      GM_setValue('iconPosition', position);
    }
  }

  // UI管理类
  class UIManager {
    constructor(configManager) {
      this.configManager = configManager;
      this.app = null;
      this.iconElement = null;
      this.elements = {};
      this.isDragging = false;
      this.isIconDragging = false;
      this.isMaximized = false;
      this.previousSize = {};
      this.apiService = null; // 将在 init 中初始化
    }

    async init() {
      this.apiService = new APIService(this.configManager);
      await this.loadLibraries();
      this.createApp();
      this.createIcon();
      this.bindEvents();
      this.restoreState();

      // 如果当前服务是 Ollama,尝试获取模型列表
      if (this.configManager.getApiService() === 'ollama') {
        this.fetchOllamaModels();
      }
    }

    async loadLibraries() {
      // 添加基础样式
      GM_addStyle(`
        #article-summary-app {
          position: fixed;
          top: 20px;
          right: 20px;
          width: 400px;
          max-height: 80vh;
          background: white;
          border-radius: 8px;
          box-shadow: 0 2px 10px rgba(0,0,0,0.1);
          z-index: 999999;
          display: flex;
          flex-direction: column;
        }

        #article-summary-icon {
          position: fixed;
          bottom: 20px;
          right: 20px;
          width: 40px;
          height: 40px;
          background: #4CAF50;
          border-radius: 50%;
          display: none; /* 默认隐藏图标 */
          align-items: center;
          justify-content: center;
          cursor: pointer;
          box-shadow: 0 2px 5px rgba(0,0,0,0.2);
          z-index: 999999;
          color: white;
        }

        #summary-header {
          padding: 12px 16px;
          border-bottom: 1px solid #eee;
          display: flex;
          justify-content: space-between;
          align-items: center;
          cursor: move;
        }

        #summary-header h3 {
          margin: 0;
          font-size: 16px;
          color: #333;
        }

        #summary-header-actions {
          display: flex;
          gap: 8px;
        }

        .header-btn {
          background: none;
          border: none;
          padding: 4px;
          cursor: pointer;
          color: #666;
          border-radius: 4px;
        }

        .header-btn:hover {
          background: #f5f5f5;
        }

        #summary-body {
          padding: 16px;
          overflow-y: auto;
          flex: 1;
        }

        .form-group {
          margin-bottom: 16px;
        }

        .form-label {
          display: block;
          margin-bottom: 4px;
          color: #666;
        }

        .form-input, .form-select {
          width: 100%;
          padding: 8px;
          border: 1px solid #ddd;
          border-radius: 4px;
          font-size: 14px;
        }

        #configPanel {
          margin-top: 8px;
          padding: 12px;
          background: #f9f9f9;
          border-radius: 4px;
        }

        #configPanel.collapsed {
          display: none;
        }

        #formatOptions {
          display: flex;
          gap: 8px;
        }

        .format-btn {
          padding: 4px 8px;
          border: 1px solid #ddd;
          border-radius: 4px;
          cursor: pointer;
          font-size: 14px;
        }

        .format-btn.active {
          background: #4CAF50;
          color: white;
          border-color: #4CAF50;
        }

        #generateBtn {
          width: 100%;
          padding: 12px;
          background: #4CAF50;
          color: white;
          border: none;
          border-radius: 4px;
          cursor: pointer;
          font-size: 14px;
          display: flex;
          align-items: center;
          justify-content: center;
          gap: 8px;
        }

        #generateBtn:disabled {
          background: #ccc;
          cursor: not-allowed;
        }

        #summaryResult {
          margin-top: 16px;
          display: none;
        }

        #summaryHeader {
          display: flex;
          justify-content: space-between;
          align-items: center;
          margin-bottom: 8px;
        }

        #summaryHeader h4 {
          margin: 0;
          color: #333;
        }

        .action-btn {
          background: none;
          border: none;
          padding: 4px 8px;
          cursor: pointer;
          color: #666;
          display: flex;
          align-items: center;
          gap: 4px;
        }

        .action-btn:hover {
          color: #4CAF50;
        }

        #loadingIndicator {
          display: none;
          text-align: center;
          padding: 20px;
        }

        .spinner {
          width: 40px;
          height: 40px;
          margin: 0 auto 16px;
          border: 3px solid #f3f3f3;
          border-top: 3px solid #4CAF50;
          border-radius: 50%;
          animation: spin 1s linear infinite;
        }

        @keyframes spin {
          0% { transform: rotate(0deg); }
          100% { transform: rotate(360deg); }
        }

        .app-minimized {
          display: none;
        }

        .icon {
          width: 20px;
          height: 20px;
        }

        .toggle-icon {
          transition: transform 0.3s;
        }

        .markdown-body {
          font-family: system-ui, -apple-system, sans-serif;
          line-height: 1.6;
          color: #333;
        }

        .markdown-body h1 { font-size: 1.5rem; margin: 1rem 0; }
        .markdown-body h2 { font-size: 1.25rem; margin: 1rem 0; }
        .markdown-body h3 { font-size: 1.1rem; margin: 1rem 0; }
        .markdown-body p { margin: 0.5rem 0; }
        .markdown-body code {
          background: #f6f8fa;
          padding: 0.2em 0.4em;
          border-radius: 3px;
          font-family: monospace;
        }
        .markdown-body pre {
          background: #f6f8fa;
          padding: 1rem;
          border-radius: 3px;
          overflow-x: auto;
        }

        #modelSelect {
          width: 100%;
          padding: 8px;
          border: 1px solid #ddd;
          border-radius: 4px;
          font-size: 14px;
        }
        
        #modelName {
          display: none;
        }
        
        .ollama-service #modelSelect {
          display: block;
        }
        
        .ollama-service #modelName {
          display: none;
        }
        
        .non-ollama-service #modelSelect {
          display: none;
        }
        
        .non-ollama-service #modelName {
          display: block;
        }
      `);

      console.log('Markdown 渲染库加载完成');
    }

    createApp() {
      this.app = document.createElement('div');
      this.app.id = 'article-summary-app';
      this.app.innerHTML = this.getAppHTML();
      document.body.appendChild(this.app);
      this.initializeElements();
    }

    createIcon() {
      this.iconElement = document.createElement('div');
      this.iconElement.id = 'article-summary-icon';
      this.iconElement.innerHTML = this.getIconHTML();
      document.body.appendChild(this.iconElement);

      // 不需要在这里设置display,因为CSS已经默认设置为none
    }

    initializeElements() {
      this.elements = {
        apiService: document.getElementById('apiService'),
        apiUrl: document.getElementById('apiUrl'),
        apiUrlContainer: document.getElementById('apiUrlContainer'),
        apiKey: document.getElementById('apiKey'),
        apiKeyContainer: document.getElementById('apiKeyContainer'),
        modelName: document.getElementById('modelName'),
        modelSelect: document.getElementById('modelSelect'),
        generateBtn: document.getElementById('generateBtn'),
        summaryResult: document.getElementById('summaryResult'),
        summaryContent: document.getElementById('summaryContent'),
        loadingIndicator: document.getElementById('loadingIndicator'),
        configToggle: document.getElementById('configToggle'),
        configPanel: document.getElementById('configPanel'),
        toggleMaxBtn: document.getElementById('toggleMaxBtn'),
        toggleMinBtn: document.getElementById('toggleMinBtn'),
        formatBtns: document.querySelectorAll('.format-btn'),
        copyBtn: document.getElementById('copyBtn')
      };
    }

    bindEvents() {
      this.bindAppEvents();
      this.bindIconEvents();
      this.bindConfigEvents();
    }

    bindAppEvents() {
      const header = document.getElementById('summary-header');
      header.addEventListener('mousedown', this.dragStart.bind(this));
      document.addEventListener('mousemove', this.drag.bind(this));
      document.addEventListener('mouseup', this.dragEnd.bind(this));

      this.elements.toggleMaxBtn.addEventListener('click', this.toggleMaximize.bind(this));
      this.elements.toggleMinBtn.addEventListener('click', this.toggleMinimize.bind(this));
      this.elements.copyBtn.addEventListener('click', this.copyContent.bind(this));
    }

    bindIconEvents() {
      this.iconElement.addEventListener('mousedown', this.iconDragStart.bind(this));
      document.addEventListener('mousemove', this.iconDrag.bind(this));
      document.addEventListener('mouseup', this.iconDragEnd.bind(this));
      this.iconElement.addEventListener('click', this.toggleApp.bind(this));
    }

    bindConfigEvents() {
      this.elements.apiService.addEventListener('change', this.handleApiServiceChange.bind(this));
      this.elements.apiUrl.addEventListener('change', this.handleConfigChange.bind(this));
      this.elements.apiKey.addEventListener('change', this.handleConfigChange.bind(this));
      this.elements.modelName.addEventListener('change', this.handleConfigChange.bind(this));
      this.elements.modelSelect.addEventListener('change', this.handleModelSelectChange.bind(this));
      this.elements.configToggle.addEventListener('click', this.toggleConfig.bind(this));
      this.elements.formatBtns.forEach(btn => {
        btn.addEventListener('click', this.handleFormatChange.bind(this));
      });
    }

    restoreState() {
      try {
        const configs = this.configManager.getConfigs();
        const apiService = this.configManager.getApiService();

        // 确保服务配置存在
        if (!configs[apiService]) {
          configs[apiService] = {
            url: apiService === 'ollama' ? 'http://localhost:11434/api/chat' : '',
            model: apiService === 'ollama' ? 'llama2' : '',
            key: ''
          };
          // 保存新创建的配置
          this.configManager.setConfigs(configs);
        }

        const currentConfig = configs[apiService];

        // 设置表单值
        this.elements.apiKey.value = currentConfig.key || '';
        this.elements.modelName.value = currentConfig.model || '';
        this.elements.apiUrl.value = currentConfig.url || '';

        // 显示/隐藏 API Key 输入框
        this.elements.apiKeyContainer.style.display = apiService === 'ollama' ? 'none' : 'block';

        // 根据服务类型添加类名
        if (apiService === 'ollama') {
          this.app.classList.add('ollama-service');
          this.app.classList.remove('non-ollama-service');

          // 设置选中的模型
          const modelValue = currentConfig.model || 'llama2';
          const option = Array.from(this.elements.modelSelect.options).find(opt => opt.value === modelValue);
          if (option) {
            this.elements.modelSelect.value = modelValue;
          } else {
            this.elements.modelSelect.value = 'llama2';
          }

          // 尝试获取 Ollama 模型列表
          this.fetchOllamaModels();
        } else {
          this.app.classList.remove('ollama-service');
          this.app.classList.add('non-ollama-service');
        }

        this.elements.apiService.value = apiService;

        const format = this.configManager.getOutputFormat();
        this.elements.formatBtns.forEach(btn => {
          if (btn.dataset.format === format) {
            btn.classList.add('active');
          } else {
            btn.classList.remove('active');
          }
        });

        const configCollapsed = this.configManager.getConfigCollapsed();
        if (configCollapsed) {
          this.elements.configPanel.classList.add('collapsed');
          this.elements.configToggle.querySelector('.toggle-icon').style.transform = 'rotate(-90deg)';
        }

        // 恢复最小化状态 - 使用直接的DOM操作
        const appMinimized = this.configManager.getAppMinimized();
        console.log('恢复状态: 最小化状态 =', appMinimized);

        if (appMinimized) {
          // 直接设置显示状态
          document.getElementById('article-summary-app').style.display = 'none';
          document.getElementById('article-summary-icon').style.display = 'flex';

          console.log('已恢复最小化状态,图标显示状态:', document.getElementById('article-summary-icon').style.display);
        } else {
          // 直接设置显示状态
          document.getElementById('article-summary-app').style.display = 'flex';
          document.getElementById('article-summary-icon').style.display = 'none';

          console.log('已恢复正常状态,图标显示状态:', document.getElementById('article-summary-icon').style.display);
        }

        // 恢复位置
        const appPosition = this.configManager.getAppPosition();
        if (appPosition) {
          this.app.style.left = appPosition.x + 'px';
          this.app.style.top = appPosition.y + 'px';
          // 确保right和bottom属性被移除,避免位置冲突
          this.app.style.right = 'auto';
          this.app.style.bottom = 'auto';
        }

        const iconPosition = this.configManager.getIconPosition();
        if (iconPosition) {
          this.iconElement.style.left = iconPosition.x + 'px';
          this.iconElement.style.top = iconPosition.y + 'px';
          // 确保right和bottom属性被移除,避免位置冲突
          this.iconElement.style.right = 'auto';
          this.iconElement.style.bottom = 'auto';
        }
      } catch (error) {
        console.error('恢复状态过程中出错:', error);
      }
    }

    // 拖拽相关方法
    dragStart(e) {
      this.isDragging = true;
      this.initialX = e.clientX - this.app.offsetLeft;
      this.initialY = e.clientY - this.app.offsetTop;
    }

    drag(e) {
      if (this.isDragging) {
        e.preventDefault();
        const currentX = Math.max(0, Math.min(
          e.clientX - this.initialX,
          window.innerWidth - this.app.offsetWidth
        ));
        const currentY = Math.max(0, Math.min(
          e.clientY - this.initialY,
          window.innerHeight - this.app.offsetHeight
        ));

        this.app.style.left = currentX + 'px';
        this.app.style.top = currentY + 'px';
      }
    }

    dragEnd() {
      if (this.isDragging) {
        this.isDragging = false;
        const position = {
          x: parseInt(this.app.style.left),
          y: parseInt(this.app.style.top)
        };
        this.configManager.setAppPosition(position);
      }
    }

    // 图标拖拽相关方法
    iconDragStart(e) {
      this.isIconDragging = true;
      this.iconInitialX = e.clientX - this.iconElement.offsetLeft;
      this.iconInitialY = e.clientY - this.iconElement.offsetTop;
      this.iconElement.style.cursor = 'grabbing';
    }

    iconDrag(e) {
      if (this.isIconDragging) {
        e.preventDefault();
        const currentX = Math.max(0, Math.min(
          e.clientX - this.iconInitialX,
          window.innerWidth - this.iconElement.offsetWidth
        ));
        const currentY = Math.max(0, Math.min(
          e.clientY - this.iconInitialY,
          window.innerHeight - this.iconElement.offsetHeight
        ));

        this.iconElement.style.left = currentX + 'px';
        this.iconElement.style.top = currentY + 'px';
        this.iconElement.style.right = 'auto';
      }
    }

    iconDragEnd() {
      if (this.isIconDragging) {
        this.isIconDragging = false;
        this.iconElement.style.cursor = 'pointer';
        const position = {
          x: parseInt(this.iconElement.style.left),
          y: parseInt(this.iconElement.style.top)
        };
        this.configManager.setIconPosition(position);
      }
    }

    // 配置相关方法
    handleApiServiceChange() {
      const service = this.elements.apiService.value;
      const configs = this.configManager.getConfigs();

      // 确保服务配置存在,如果不存在则创建默认配置
      if (!configs[service]) {
        configs[service] = {
          url: service === 'ollama' ? 'http://localhost:11434/api/chat' : '',
          model: service === 'ollama' ? 'llama2' : '',
          key: ''
        };
        // 保存新创建的配置
        this.configManager.setConfigs(configs);
      }

      const currentConfig = configs[service];

      // 设置表单值
      this.elements.apiKey.value = currentConfig.key || '';
      this.elements.modelName.value = currentConfig.model || '';
      this.elements.apiUrl.value = currentConfig.url || '';

      // 显示/隐藏 API Key 输入框
      this.elements.apiKeyContainer.style.display = service === 'ollama' ? 'none' : 'block';

      // 根据服务类型添加类名
      if (service === 'ollama') {
        this.app.classList.add('ollama-service');
        this.app.classList.remove('non-ollama-service');

        // 设置选中的模型
        const modelValue = currentConfig.model || 'llama2';
        const option = Array.from(this.elements.modelSelect.options).find(opt => opt.value === modelValue);
        if (option) {
          this.elements.modelSelect.value = modelValue;
        } else {
          this.elements.modelSelect.value = 'llama2';
        }

        // 尝试获取 Ollama 模型列表
        this.fetchOllamaModels();
      } else {
        this.app.classList.remove('ollama-service');
        this.app.classList.add('non-ollama-service');
      }

      this.configManager.setApiService(service);
    }

    handleConfigChange() {
      const service = this.elements.apiService.value;
      const configs = this.configManager.getConfigs();

      // 确保服务配置存在
      if (!configs[service]) {
        configs[service] = {
          url: service === 'ollama' ? 'http://localhost:11434/api/chat' : '',
          model: service === 'ollama' ? 'llama2' : '',
          key: ''
        };
      }

      // 获取当前表单值
      const apiKey = this.elements.apiKey.value || '';
      const modelName = service === 'ollama' ?
        (this.elements.modelSelect.value || 'llama2') :
        (this.elements.modelName.value || '');
      const apiUrl = this.elements.apiUrl.value ||
        (service === 'ollama' ? 'http://localhost:11434/api/chat' : '');

      // 更新配置
      configs[service] = {
        ...configs[service],
        key: apiKey,
        model: modelName,
        url: apiUrl
      };

      // 保存配置
      this.configManager.setConfigs(configs);
    }

    toggleConfig() {
      this.elements.configPanel.classList.toggle('collapsed');
      const isCollapsed = this.elements.configPanel.classList.contains('collapsed');
      const toggleIcon = this.elements.configToggle.querySelector('.toggle-icon');
      toggleIcon.style.transform = isCollapsed ? 'rotate(-90deg)' : '';
      this.configManager.setConfigCollapsed(isCollapsed);
    }

    handleFormatChange(e) {
      this.elements.formatBtns.forEach(btn => btn.classList.remove('active'));
      e.target.classList.add('active');
      this.configManager.setOutputFormat(e.target.dataset.format);
    }

    handleModelSelectChange() {
      // 确保选择的值有效
      const selectedModel = this.elements.modelSelect.value || 'llama2';
      this.elements.modelName.value = selectedModel;

      // 触发配置更新
      this.handleConfigChange();
    }

    // UI状态相关方法
    toggleMaximize() {
      if (!this.isMaximized) {
        this.previousSize = {
          width: this.app.style.width,
          height: this.app.style.height,
          left: this.app.style.left,
          top: this.app.style.top
        };

        this.app.style.width = '100%';
        this.app.style.height = '100vh';
        this.app.style.left = '0';
        this.app.style.top = '0';

        this.elements.toggleMaxBtn.innerHTML = this.getMaximizeIcon();
      } else {
        Object.assign(this.app.style, this.previousSize);
        this.elements.toggleMaxBtn.innerHTML = this.getRestoreIcon();
      }
      this.isMaximized = !this.isMaximized;
    }

    toggleMinimize() {
      try {
        // 直接操作DOM元素
        document.getElementById('article-summary-app').style.display = 'none';
        document.getElementById('article-summary-icon').style.display = 'flex';

        // 保存状态
        this.configManager.setAppMinimized(true);

        console.log('应用已最小化,图标显示状态:', document.getElementById('article-summary-icon').style.display);
      } catch (error) {
        console.error('最小化过程中出错:', error);
      }
    }

    toggleApp() {
      try {
        // 直接操作DOM元素
        document.getElementById('article-summary-app').style.display = 'flex';
        document.getElementById('article-summary-icon').style.display = 'none';

        // 保存状态
        this.configManager.setAppMinimized(false);

        console.log('应用已恢复,图标显示状态:', document.getElementById('article-summary-icon').style.display);
      } catch (error) {
        console.error('恢复应用过程中出错:', error);
      }
    }

    // 工具方法
    copyContent() {
      const outputFormat = document.querySelector('.format-btn.active').dataset.format;
      let textToCopy = outputFormat === 'markdown'
        ? this.elements.summaryContent.getAttribute('data-markdown') || this.elements.summaryContent.textContent
        : this.elements.summaryContent.textContent;

      navigator.clipboard.writeText(textToCopy).then(() => {
        const originalHTML = this.elements.copyBtn.innerHTML;
        this.elements.copyBtn.innerHTML = this.getCopiedIcon();
        setTimeout(() => {
          this.elements.copyBtn.innerHTML = originalHTML;
        }, 2000);
      }).catch(err => {
        console.error('复制失败:', err);
        alert('复制失败,请手动选择文本复制');
      });
    }

    // HTML模板方法
    getAppHTML() {
      return `
        <div id="summary-header">
          <h3>文章总结助手</h3>
          <div id="summary-header-actions">
            <button id="toggleMaxBtn" class="header-btn" data-tooltip="最大化">
              <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                <path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"></path>
              </svg>
            </button>
            <button id="toggleMinBtn" class="header-btn" data-tooltip="最小化">
              <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                <path d="M20 12H4"></path>
              </svg>
            </button>
          </div>
        </div>
        <div id="summary-body">
          <div id="config-section">
            <div id="configToggle">
              <span>配置选项</span>
              <svg class="icon toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                <path d="M19 9l-7 7-7-7"></path>
              </svg>
            </div>
            <div id="configPanel">
              <div class="form-group">
                <label class="form-label" for="apiService">API服务</label>
                <select id="apiService" class="form-select">
                  <option value="ollama">Ollama (本地)</option>
                  <option value="gptgod">GPT God</option>
                  <option value="deepseek">DeepSeek</option>
                  <option value="custom">自定义</option>
                </select>
              </div>
              
              <div id="apiUrlContainer" class="form-group">
                <label class="form-label" for="apiUrl">API地址</label>
                <input type="text" id="apiUrl" class="form-input" placeholder="http://localhost:11434/api/chat">
              </div>
              
              <div class="form-group" id="apiKeyContainer">
                <label class="form-label" for="apiKey">API Key</label>
                <input type="password" id="apiKey" class="form-input" placeholder="sk-...">
              </div>
              
              <div class="form-group">
                <label class="form-label" for="modelName">模型</label>
                <select id="modelSelect" class="form-select">
                  <option value="llama2">llama2</option>
                  <option value="llama2:13b">llama2:13b</option>
                  <option value="llama2:70b">llama2:70b</option>
                  <option value="mistral">mistral</option>
                  <option value="mixtral">mixtral</option>
                  <option value="gemma:2b">gemma:2b</option>
                  <option value="gemma:7b">gemma:7b</option>
                  <option value="qwen:14b">qwen:14b</option>
                  <option value="qwen:72b">qwen:72b</option>
                  <option value="phi3:mini">phi3:mini</option>
                  <option value="phi3:small">phi3:small</option>
                  <option value="phi3:medium">phi3:medium</option>
                  <option value="yi:34b">yi:34b</option>
                  <option value="vicuna:13b">vicuna:13b</option>
                  <option value="vicuna:33b">vicuna:33b</option>
                  <option value="codellama">codellama</option>
                  <option value="wizardcoder">wizardcoder</option>
                  <option value="nous-hermes2">nous-hermes2</option>
                  <option value="neural-chat">neural-chat</option>
                  <option value="openchat">openchat</option>
                  <option value="dolphin-mixtral">dolphin-mixtral</option>
                  <option value="starling-lm">starling-lm</option>
                </select>
                <input type="text" id="modelName" class="form-input" placeholder="模型名称">
              </div>
              
              <div class="form-group">
                <label class="form-label">输出格式</label>
                <div id="formatOptions">
                  <span class="format-btn active" data-format="markdown">Markdown</span>
                  <span class="format-btn" data-format="bullet">要点列表</span>
                  <span class="format-btn" data-format="paragraph">段落</span>
                </div>
              </div>
            </div>
          </div>
          
          <button type="button" id="generateBtn">
            <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
              <path d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
            </svg>
            生成总结
          </button>
          
          <div id="summaryResult">
            <div id="summaryHeader">
              <h4>文章总结</h4>
              <div id="summaryActions">
                <button id="copyBtn" class="action-btn" data-tooltip="复制到剪贴板">
                  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <path d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"></path>
                  </svg>
                  复制
                </button>
              </div>
            </div>
            <div id="summaryContent" class="markdown-body">
              <textarea class="content-textarea resizable"></textarea>
            </div>
          </div>
          
          <div id="loadingIndicator">
            <div class="spinner"></div>
            <p>正在生成总结,请稍候...</p>
          </div>
        </div>
      `;
    }

    getIconHTML() {
      return `
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <path d="M9 12h6m-6 4h6m2-10H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V8a2 2 0 00-2-2z"></path>
          </svg>
        `;
    }

    getMaximizeIcon() {
      return `
          <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"></path>
          </svg>
        `;
    }

    getRestoreIcon() {
      return `
          <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"></path>
          </svg>
        `;
    }

    getCopiedIcon() {
      return `
          <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <path d="M5 13l4 4L19 7"></path>
          </svg>
          已复制
        `;
    }

    async fetchOllamaModels() {
      try {
        const models = await this.apiService.fetchOllamaModels();
        if (models && models.length > 0) {
          // 清空现有选项
          this.elements.modelSelect.innerHTML = '';

          // 添加获取到的模型选项
          models.forEach(model => {
            const option = document.createElement('option');
            option.value = model;
            option.textContent = model;
            this.elements.modelSelect.appendChild(option);
          });

          // 设置当前选中的模型
          const configs = this.configManager.getConfigs();
          const currentModel = configs.ollama.model;
          if (currentModel && models.includes(currentModel)) {
            this.elements.modelSelect.value = currentModel;
          } else if (models.includes('llama2')) {
            this.elements.modelSelect.value = 'llama2';
          } else if (models.length > 0) {
            this.elements.modelSelect.value = models[0];
          }

          console.log('成功获取 Ollama 模型列表:', models);
        }
      } catch (error) {
        console.error('获取 Ollama 模型列表失败:', error);
      }
    }
  }

  // 文章提取类
  class ArticleExtractor {
    constructor() {
      this.selectors = [
        '#js_content',
        '.RichText',
        '.article-content',
        '#article_content',
        '#cnblogs_post_body',
        'article',
        '.article',
        '.post-content',
        '.content',
        '.entry-content',
        '.article-content',
        'main',
        '#main',
        '.main'
      ];

      this.removeSelectors = [
        'script',
        'style',
        'iframe',
        'nav',
        'header',
        'footer',
        '.advertisement',
        '.ad',
        '.ads',
        '.social-share',
        '.related-posts',
        '.comments',
        '.comment',
        '.author-info',
        '.article-meta',
        '.article-info',
        '.article-header',
        '.article-footer',
        '#article-summary-app'
      ];
    }

    async extract() {
      // 尝试使用不同的选择器获取内容
      for (const selector of this.selectors) {
        const element = document.querySelector(selector);
        if (element) {
          const content = this.processElement(element);
          if (content.length > 100) {
            return content;
          }
        }
      }

      // 如果上述方法都失败,尝试获取整个页面的主要内容
      const content = this.processElement(document.body);
      if (content.length < 100) {
        throw new Error('无法获取足够的文章内容');
      }

      return content;
    }

    processElement(element) {
      const clone = element.cloneNode(true);
      this.removeUnwantedElements(clone);
      return this.cleanText(clone.innerText);
    }

    removeUnwantedElements(element) {
      this.removeSelectors.forEach(selector => {
        const elements = element.querySelectorAll(selector);
        elements.forEach(el => el.remove());
      });
    }

    cleanText(text) {
      return text
        .replace(/\s+/g, ' ')
        .replace(/\n\s*\n/g, '\n')
        .trim();
    }
  }

  // API服务类
  class APIService {
    constructor(configManager) {
      this.configManager = configManager;
    }

    async generateSummary(content) {
      const configs = this.configManager.getConfigs();
      const apiService = this.configManager.getApiService();
      const currentConfig = configs[apiService];
      const outputFormat = this.configManager.getOutputFormat();

      const apiEndpoint = this.getApiEndpoint(apiService, currentConfig);
      const systemPrompt = this.getSystemPrompt(outputFormat);
      const messages = this.createMessages(systemPrompt, content);

      return this.makeRequest(apiEndpoint, currentConfig, messages);
    }

    async fetchOllamaModels() {
      return new Promise((resolve, reject) => {
        const ollamaConfig = this.configManager.getConfigs().ollama;
        // 从 API URL 中提取基础 URL
        const baseUrl = ollamaConfig.url.split('/api/')[0] || 'http://localhost:11434';
        const modelsEndpoint = `${baseUrl}/api/tags`;

        GM_xmlhttpRequest({
          method: 'GET',
          url: modelsEndpoint,
          headers: {
            'Content-Type': 'application/json'
          },
          onload: (response) => {
            try {
              if (response.status >= 400) {
                console.warn('获取 Ollama 模型列表失败:', response.statusText);
                resolve([]); // 失败时返回空数组,使用默认模型列表
                return;
              }

              const data = JSON.parse(response.responseText);
              if (data.models && Array.isArray(data.models)) {
                // 提取模型名称
                const models = data.models.map(model => model.name);
                resolve(models);
              } else {
                console.warn('Ollama API 返回的模型列表格式异常:', data);
                resolve([]);
              }
            } catch (error) {
              console.error('解析 Ollama 模型列表失败:', error);
              resolve([]);
            }
          },
          onerror: (error) => {
            console.error('获取 Ollama 模型列表请求失败:', error);
            resolve([]); // 失败时返回空数组,使用默认模型列表
          }
        });
      });
    }

    getApiEndpoint(apiService, config) {
      return config.url;
    }

    getSystemPrompt(format) {
      const prompts = {
        markdown: "请用中文总结以下文章的主要内容,以标准Markdown格式输出,包括标题、小标题和要点。确保格式规范,便于阅读。",
        bullet: "请用中文总结以下文章的主要内容,以简洁的要点列表形式输出,每个要点前使用'- '标记。",
        paragraph: "请用中文总结以下文章的主要内容,以连贯的段落形式输出,突出文章的核心观点和结论。"
      };
      return prompts[format] || "请用中文总结以下文章的主要内容,以简洁的方式列出重点。";
    }

    createMessages(systemPrompt, content) {
      const apiService = this.configManager.getApiService();
      if (apiService === 'ollama') {
        return [
          { role: "system", content: systemPrompt },
          { role: "user", content: content }
        ];
      } else {
        return [
          { role: "system", content: systemPrompt },
          { role: "user", content: content }
        ];
      }
    }

    makeRequest(endpoint, config, messages) {
      return new Promise((resolve, reject) => {
        const apiService = this.configManager.getApiService();

        // 确保配置有效
        if (!endpoint) {
          reject(new Error('API 地址无效'));
          return;
        }

        if (!config.model) {
          reject(new Error('模型名称无效'));
          return;
        }

        // 构建请求数据
        const requestData = {
          model: config.model,
          messages: messages,
          stream: false
        };

        // 构建请求头
        const headers = {
          'Content-Type': 'application/json'
        };

        // 非 Ollama 服务需要 API Key
        if (apiService !== 'ollama' && config.key) {
          headers['Authorization'] = `Bearer ${config.key}`;
        }

        // 发送请求
        GM_xmlhttpRequest({
          method: 'POST',
          url: endpoint,
          headers: headers,
          data: JSON.stringify(requestData),
          onload: this.handleResponse.bind(this, resolve, reject, apiService),
          onerror: (error) => reject(new Error('网络请求失败: ' + (error.message || '未知错误')))
        });
      });
    }

    handleResponse(resolve, reject, apiService, response) {
      try {
        // 检查响应是否为 HTML
        if (response.responseText.trim().startsWith('<')) {
          reject(new Error(`API返回了HTML而不是JSON (状态码: ${response.status})`));
          return;
        }

        // 检查状态码
        if (response.status >= 400) {
          try {
            const data = JSON.parse(response.responseText);
            reject(new Error(data.error?.message || `请求失败 (${response.status})`));
          } catch (e) {
            reject(new Error(`请求失败 (${response.status}): ${response.responseText.substring(0, 100)}`));
          }
          return;
        }

        // 解析响应数据
        const data = JSON.parse(response.responseText);

        // 检查错误
        if (data.error) {
          reject(new Error(data.error.message || '未知错误'));
          return;
        }

        // 根据不同的 API 服务提取内容
        if (apiService === 'ollama' && data.message) {
          // Ollama API 响应格式
          resolve(data.message.content);
        } else if (data.choices && data.choices.length > 0 && data.choices[0].message) {
          // OpenAI 兼容的 API 响应格式
          resolve(data.choices[0].message.content);
        } else {
          // 未知的响应格式
          console.warn('未知的 API 响应格式:', data);

          // 尝试从响应中提取可能的内容
          if (data.content) {
            resolve(data.content);
          } else if (data.text) {
            resolve(data.text);
          } else if (data.result) {
            resolve(data.result);
          } else if (data.response) {
            resolve(data.response);
          } else if (data.output) {
            resolve(data.output);
          } else if (data.generated_text) {
            resolve(data.generated_text);
          } else {
            reject(new Error('API 返回格式异常,无法提取内容'));
          }
        }
      } catch (error) {
        reject(new Error(`解析API响应失败: ${error.message || '未知错误'}`));
      }
    }
  }

  // 主应用类
  class ArticleSummaryApp {
    constructor() {
      this.configManager = new ConfigManager();
      this.uiManager = new UIManager(this.configManager);
      this.articleExtractor = new ArticleExtractor();
      this.apiService = new APIService(this.configManager);
      this.version = '0.2.1'; // 更新版本号
    }

    async init() {
      this.logScriptInfo();
      await this.uiManager.init();
      this.bindGenerateButton();
    }

    logScriptInfo() {
      const styles = {
        title: 'font-size: 16px; font-weight: bold; color: #4CAF50;',
        subtitle: 'font-size: 14px; font-weight: bold; color: #2196F3;',
        normal: 'font-size: 12px; color: #333;',
        key: 'font-size: 12px; color: #E91E63;',
        value: 'font-size: 12px; color: #3F51B5;'
      };

      console.log('%c网页文章总结助手', styles.title);
      console.log('%c基本信息', styles.subtitle);
      console.log(`%c版本:%c ${this.version}`, styles.key, styles.value);
      console.log(`%c作者:%c h7ml <[email protected]>`, styles.key, styles.value);
      console.log(`%c描述:%c 自动总结网页文章内容,支持多种格式输出,适用于各类文章网站`, styles.key, styles.value);

      console.log('%c支持的API服务', styles.subtitle);
      console.log(`%c- Ollama:%c 本地大语言模型服务,无需API Key`, styles.key, styles.normal);
      console.log(`%c- GPT God:%c 支持多种OpenAI模型`, styles.key, styles.normal);
      console.log(`%c- DeepSeek:%c 支持DeepSeek系列模型`, styles.key, styles.normal);
      console.log(`%c- 自定义:%c 支持任何兼容OpenAI API格式的服务`, styles.key, styles.normal);

      console.log('%c支持的功能', styles.subtitle);
      console.log(`%c- 自动提取:%c 智能提取网页文章内容`, styles.key, styles.normal);
      console.log(`%c- 多种格式:%c 支持Markdown、要点列表、段落等输出格式`, styles.key, styles.normal);
      console.log(`%c- 动态获取:%c 自动获取Ollama本地已安装模型列表`, styles.key, styles.normal);
      console.log(`%c- 界面定制:%c 支持拖拽、最小化、最大化等操作`, styles.key, styles.normal);

      console.log('%c当前配置', styles.subtitle);
      const configs = this.configManager.getConfigs();
      const apiService = this.configManager.getApiService();
      const currentConfig = configs[apiService] || {};
      console.log(`%c当前API服务:%c ${apiService}`, styles.key, styles.value);
      console.log(`%c当前模型:%c ${currentConfig.model || '未设置'}`, styles.key, styles.value);
      console.log(`%c当前API地址:%c ${currentConfig.url || '未设置'}`, styles.key, styles.value);
      console.log(`%c输出格式:%c ${this.configManager.getOutputFormat()}`, styles.key, styles.value);

      console.log('%c使用提示', styles.subtitle);
      console.log(`%c- 点击右上角按钮可最小化或最大化界面`, styles.normal);
      console.log(`%c- 最小化后可通过右下角图标恢复界面`, styles.normal);
      console.log(`%c- 可拖动顶部标题栏移动位置`, styles.normal);
      console.log(`%c- 使用Ollama服务时会自动获取本地已安装模型`, styles.normal);
    }

    bindGenerateButton() {
      this.uiManager.elements.generateBtn.addEventListener('click', this.handleGenerate.bind(this));
    }

    async handleGenerate() {
      const apiService = this.uiManager.elements.apiService.value;
      const apiKey = this.uiManager.elements.apiKey.value.trim();
      const apiUrl = this.uiManager.elements.apiUrl.value.trim();

      // 获取当前配置
      const configs = this.configManager.getConfigs();
      const currentConfig = configs[apiService] || {
        url: apiService === 'ollama' ? 'http://localhost:11434/api/chat' : '',
        model: apiService === 'ollama' ? 'llama2' : '',
        key: ''
      };

      // 检查 API URL 是否有效
      if (!apiUrl) {
        alert('请输入有效的 API 地址');
        return;
      }

      // 检查 API Key(Ollama 不需要)
      if (apiService !== 'ollama' && !apiKey) {
        alert('请输入有效的 API Key');
        return;
      }

      // 检查模型是否有效
      const modelName = apiService === 'ollama' ?
        (this.uiManager.elements.modelSelect.value || 'llama2') :
        (this.uiManager.elements.modelName.value || '');

      if (!modelName) {
        alert('请选择或输入有效的模型名称');
        return;
      }

      this.showLoading();

      try {
        const content = await this.articleExtractor.extract();
        const summary = await this.apiService.generateSummary(content);
        this.displaySummary(summary);
      } catch (error) {
        this.handleError(error);
      } finally {
        this.hideLoading();
      }
    }

    showLoading() {
      this.uiManager.elements.loadingIndicator.style.display = 'block';
      this.uiManager.elements.generateBtn.disabled = true;
      this.uiManager.elements.generateBtn.innerHTML = `
          <svg class="icon spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <circle cx="12" cy="12" r="10" stroke-opacity="0.25" stroke-dasharray="30" stroke-dashoffset="0"></circle>
            <circle cx="12" cy="12" r="10" stroke-dasharray="30" stroke-dashoffset="15"></circle>
          </svg>
          生成中...
        `;
    }

    hideLoading() {
      this.uiManager.elements.loadingIndicator.style.display = 'none';
      this.uiManager.elements.generateBtn.disabled = false;
      this.uiManager.elements.generateBtn.innerHTML = `
          <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <path d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
          </svg>
          生成总结
        `;
    }

    displaySummary(summary) {
      const outputFormat = this.configManager.getOutputFormat();
      const summaryContent = this.uiManager.elements.summaryContent;

      if (outputFormat === 'markdown') {
        summaryContent.setAttribute('data-markdown', summary);
        summaryContent.innerHTML = this.simpleMarkdownRender(summary);
      } else {
        summaryContent.innerHTML = this.simpleMarkdownRender(summary);
      }

      this.uiManager.elements.summaryResult.style.display = 'block';
    }

    handleError(error) {
      let errorMsg = error.message;
      if (errorMsg.includes('Authentication Fails') || errorMsg.includes('no such user')) {
        errorMsg = 'API Key 无效或已过期,请更新您的 API Key';
      } else if (errorMsg.includes('rate limit')) {
        errorMsg = 'API 调用次数已达上限,请稍后再试';
      }
      alert('生成总结失败:' + errorMsg);
      console.error('API 错误详情:', error);
    }

    simpleMarkdownRender(text) {
      let html = '<div class="summary-container">';
      const content = text
        .replace(/^# (.*$)/gm, '<h1>$1</h1>')
        .replace(/^## (.*$)/gm, '<h2>$1</h2>')
        .replace(/^### (.*$)/gm, '<h3>$1</h3>')
        .replace(/^\d+\.\s+\*\*(.*?)\*\*:([\s\S]*?)(?=(?:\d+\.|$))/gm, (match, title, items) => {
          const listItems = items
            .split(/\n\s*-\s+/)
            .filter(item => item.trim())
            .map(item => `<li>${item.trim()}</li>`)
            .join('');
          return `<h2>${title}</h2><ul>${listItems}</ul>`;
        })
        .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
        .replace(/`([^`]+)`/g, '<code>$1</code>')
        .replace(/([^\n]+)(?:\n|$)/g, (match, p1) => {
          if (!p1.startsWith('<') && p1.trim()) {
            return `<p>${p1}</p>`;
          }
          return p1;
        });
      html += content + '</div>';
      return html;
    }
  }

  // 初始化应用
  const app = new ArticleSummaryApp();
  app.init();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址