NodeImage图片上传助手

在NodeSeek编辑器中粘贴图片自动上传到NodeImage图床

// ==UserScript==
// @name         NodeImage图片上传助手
// @namespace    https://www.nodeimage.com/
// @version      1.0.2
// @description  在NodeSeek编辑器中粘贴图片自动上传到NodeImage图床
// @author       shuai
// @match        *://www.nodeseek.com/*
// @match        *://nodeimage.com/*
// @match        *://*.nodeimage.com/*
// @icon         https://cdn.nodeimage.com/favicon.ico
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @connect      nodeimage.com
// @connect      api.nodeimage.com
// @license      MIT
// ==/UserScript==

/**
 * @file NodeImage Uploader - Tampermonkey Script
 * @author shuai
 * @version 1.0.0
 *
 * This script integrates the NodeImage image hosting service with the NodeSeek
 * website's editor. It allows users to upload images by pasting, dragging and
 * dropping, or using a dedicated toolbar button. It handles authentication,
 * provides real-time status feedback, and automatically inserts Markdown links
 * into the editor upon successful upload.
 */

(() => {
  'use strict';

  // ===== 全局配置 (Global Configuration) =====
  // 集中管理所有可配置的常量,便于维护和调整。
  const APP = {
    api: {
      key: GM_getValue('nodeimage_apiKey', ''),
      setKey: key => {
        GM_setValue('nodeimage_apiKey', key);
        APP.api.key = key;
        UI.updateState();
      },
      clearKey: () => {
        GM_deleteValue('nodeimage_apiKey');
        APP.api.key = '';
        UI.updateState();
      },
      endpoints: {
        upload: 'https://api.nodeimage.com/api/upload',
        apiKey: 'https://api.nodeimage.com/api/user/api-key'
      }
    },
    site: {
      url: 'https://www.nodeimage.com'
    },
    storage: {
      keys: {
        loginCheck: 'nodeimage_login_check',
        loginStatus: 'nodeimage_login_status',
        logout: 'nodeimage_logout'
      },
      get: key => localStorage.getItem(APP.storage.keys[key]),
      set: (key, value) => localStorage.setItem(APP.storage.keys[key], value),
      remove: key => localStorage.removeItem(APP.storage.keys[key])
    },
    retry: {
      max: 2,
      delay: 1000
    },
    statusTimeout: 2000,
    auth: {
      recentLoginGracePeriod: 30000, // 30秒内检查近期登录
      loginCheckInterval: 3000, // 轮询登录状态的间隔
      loginCheckTimeout: 300000 // 轮询登录状态的总超时
    }
  };

  // ===== DOM选择器 (DOM Selectors) =====
  // 定义脚本中使用的所有DOM选择器。
  const SELECTORS = {
    editor: '.CodeMirror',
    toolbar: '.mde-toolbar',
    imgBtn: '.toolbar-item.i-icon.i-icon-pic[title="图片"]',
    container: '#nodeimage-toolbar-container'
  };

  // ===== 状态常量 (Status Constants) =====
  // 定义不同状态下的样式和颜色,用于UI反馈。
  const STATUS = {
    SUCCESS: { class: 'success', color: '#42d392' },
    ERROR: { class: 'error', color: '#f56c6c' },
    WARNING: { class: 'warning', color: '#e6a23c' },
    INFO: { class: 'info', color: '#0078ff' }
  };

  const MESSAGE = {
    READY: 'NodeImage已就绪',
    UPLOADING: '正在上传...',
    UPLOAD_SUCCESS: '上传成功!',
    LOGIN_EXPIRED: '登录已失效',
    LOGOUT: '已退出登录',
    RETRY: (current, max) => `重试上传 (${current}/${max})`
  };

  // ===== DOM缓存 (DOM Cache) =====
  // 缓存频繁访问的DOM元素,以提高性能并集中管理。
  const DOM = {
    editor: null,
    statusElements: new Set(),
    loginButtons: new Set(),
    getEditor: () => DOM.editor?.CodeMirror
  };

  // ===== 全局样式 (Global Styles) =====
  // 通过 GM_addStyle 注入自定义CSS,美化UI组件。
  GM_addStyle(`
    #nodeimage-status {
      margin-left: 10px;
      display: inline-block;
      font-size: 14px;
      height: 28px;
      line-height: 28px;
      transition: all 0.3s ease;
    }
    #nodeimage-status.success { color: ${STATUS.SUCCESS.color}; }
    #nodeimage-status.error { color: ${STATUS.ERROR.color}; }
    #nodeimage-status.warning { color: ${STATUS.WARNING.color}; }
    #nodeimage-status.info { color: ${STATUS.INFO.color}; }

    .nodeimage-login-btn {
      cursor: pointer;
      margin-left: 10px;
      color: ${STATUS.WARNING.color};
      font-size: 14px;
      background: rgba(230, 162, 60, 0.1);
      padding: 3px 8px;
      border-radius: 4px;
      border: 1px solid rgba(230, 162, 60, 0.2);
    }

    .nodeimage-toolbar-container {
      display: flex;
      align-items: center;
      margin-left: 10px;
    }
  `);

  // ===== 工具函数 (Utility Functions) =====
  // 封装通用的、无副作用的辅助函数。
  const Utils = {
    /**
     * 判断当前是否在NodeImage相关网站。
     * @returns {boolean}
     */
    isNodeImageSite: () => /^(.*\.)?nodeimage\.com$/.test(window.location.hostname),

    /**
     * 异步等待指定选择器的元素出现在DOM中。
     * 对于单页应用(SPA)中动态加载的元素特别有用。
     * @param {string} selector - CSS选择器。
     * @returns {Promise<Element>}
     */
    waitForElement: selector => new Promise(res => {
      const el = document.querySelector(selector);
      if (el) return res(el);
      new MutationObserver((_, o) => {
        const found = document.querySelector(selector);
        if (found) { o.disconnect(); res(found); }
      }).observe(document.body, { childList: true, subtree: true });
    }),

    /**
     * 检查当前活动元素是否在编辑器中。
     * 用于判断用户是否在编辑器内进行粘贴、拖拽等操作。
     * @returns {boolean}
     */
    isEditingInEditor: () => {
      const a = document.activeElement;
      return a && (a.classList.contains('CodeMirror') || a.closest('.CodeMirror') || a.tagName === 'TEXTAREA');
    },

    /**
     * 创建一个文件输入元素,用于触发文件选择对话框。
     * @param {Function} cb - 文件选择完成后调用的回调函数。
     */
    createFileInput: cb => {
      const i = Object.assign(document.createElement('input'), { type: 'file', multiple: true, accept: 'image/*' });
      i.onchange = e => cb([...e.target.files]);
      i.click();
    },

    /**
     * 创建一个Promise来进行延迟。
     * @param {number} ms - 延迟的毫秒数。
     * @returns {Promise<void>}
     */
    delay: ms => new Promise(r => setTimeout(r, ms))
  };

  // ===== API通信 (API Communication) =====
  // 负责与NodeImage后端API进行所有网络交互。
  const API = {
    /**
     * 封装的通用GM_xmlhttpRequest请求,返回一个Promise。
     * @param {object} options - 请求配置。
     * @param {string} options.url - 请求URL。
     * @param {string} [options.method='GET'] - 请求方法。
     * @param {FormData|null} [options.data=null] - 请求体数据。
     * @param {object} [options.headers={}] - 自定义请求头。
     * @param {boolean} [options.withAuth=false] - 是否携带API Key。
     * @returns {Promise<any>}
     */
    request: ({ url, method = 'GET', data = null, headers = {}, withAuth = false }) => {
      return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
          method,
          url,
          headers: {
            'Accept': 'application/json',
            ...(withAuth && APP.api.key ? { 'X-API-Key': APP.api.key } : {}),
            ...headers
          },
          data,
          withCredentials: true,
          responseType: 'json',
          onload: response => {
            if (response.status === 200 && response.response) {
              resolve(response.response);
            } else {
              reject(response);
            }
          },
          onerror: reject
        });
      });
    },

    /**
     * 检查用户在NodeImage网站的登录状态,并获取API Key。
     * 这是脚本实现自动登录的关键部分。
     * @returns {Promise<boolean>} - 返回是否成功获取到Key。
     */
    checkLoginAndGetKey: async () => {
      try {
        const response = await API.request({ url: APP.api.endpoints.apiKey });

        if (response.api_key) {
          APP.api.setKey(response.api_key);
          return true;
        }

        if (response.error) {
          APP.api.clearKey();
        }

        return false;
      } catch (error) {
        APP.api.clearKey();
        return false;
      }
    },

    /**
     * 上传单个图片文件,包含自动重试逻辑。
     * @param {File} file - 要上传的文件对象。
     * @param {number} [retries=0] - 当前重试次数。
     * @returns {Promise<{url: string, markdown: string}>} - 返回包含图片链接的对象。
     */
    uploadImage: async (file, retries = 0) => {
      try {
        const formData = new FormData();
        formData.append('image', file);

        const result = await API.request({
          url: APP.api.endpoints.upload,
          method: 'POST',
          data: formData,
          withAuth: true
        });

        if (result.success) {
          return {
            url: result.links.direct,
            markdown: result.links.markdown
          };
        } else {
          const errorMsg = result.error || '未知错误';

          if (errorMsg.toLowerCase().match(/unauthorized|invalid api key|未授权|无效的api密钥/)) {
            APP.api.clearKey();
            throw new Error(MESSAGE.LOGIN_EXPIRED);
          }

          throw new Error(errorMsg);
        }
      } catch (error) {
        if (error.status === 401 || error.status === 403) {
          APP.api.clearKey();
          throw new Error(MESSAGE.LOGIN_EXPIRED);
        }

        if (retries < APP.retry.max) {
          setStatus(STATUS.WARNING.class, MESSAGE.RETRY(retries + 1, APP.retry.max));

          await Utils.delay(APP.retry.delay);
          return API.uploadImage(file, retries + 1);
        }

        throw error instanceof Error ? error : new Error(String(error));
      }
    }
  };

  // ===== UI与状态管理 (UI & Status Management) =====
  // 负责所有与界面显示、状态更新相关的操作。

  /**
   * 设置并显示一个状态消息。
   * @param {string} cls - 状态对应的CSS类 ('success', 'error', etc.)。
   * @param {string} msg - 要显示的消息文本。
   * @param {number} [ttl=0] - 消息显示时长(毫秒),0表示永久显示直到下次更新。
   * @returns {Promise<void>}
   */
  const setStatus = (cls, msg, ttl = 0) => {
    DOM.statusElements.forEach(el => { el.className = cls; el.textContent = msg; });
    if (ttl) return Utils.delay(ttl).then(UI.updateState);
  };

  const UI = {
    /**
     * 根据当前的登录状态,更新所有UI元素(状态文本、登录按钮等)。
     */
    updateState: () => {
      const isLoggedIn = Boolean(APP.api.key);

      DOM.loginButtons.forEach(btn => {
        btn.style.display = isLoggedIn ? 'none' : 'inline-block';
      });

      DOM.statusElements.forEach(el => {
        if (isLoggedIn) {
          el.className = STATUS.SUCCESS.class;
          el.textContent = MESSAGE.READY;
        } else {
          el.textContent = '';
        }
      });
    },

    /**
     * 打开NodeImage登录页面。
     * 通过localStorage设置'login_pending'状态,用于跨页面通信。
     */
    openLogin: () => {
      APP.storage.set('loginStatus', 'login_pending');
      window.open(APP.site.url, '_blank');
    },

    /**
     * 在NodeSeek编辑器的工具栏上创建并注入脚本的UI组件。
     * @param {Element} toolbar - 编辑器工具栏元素。
     */
    setupToolbar: toolbar => {
      if (!toolbar || toolbar.querySelector(SELECTORS.container)) return;

      const container = document.createElement('div');
      container.id = 'nodeimage-toolbar-container';
      container.className = 'nodeimage-toolbar-container';
      toolbar.appendChild(container);

      const imgBtn = toolbar.querySelector(SELECTORS.imgBtn);
      if (imgBtn) {
        const newBtn = imgBtn.cloneNode(true);
        imgBtn.parentNode.replaceChild(newBtn, imgBtn);
        newBtn.addEventListener('click', async () => {
          if (!APP.api.key || !(await Auth.checkLoginIfNeeded())) {
            UI.openLogin();
            return;
          }

          Utils.createFileInput(ImageHandler.handleFiles);
        });
      }

      const statusEl = document.createElement('div');
      statusEl.id = 'nodeimage-status';
      statusEl.className = STATUS.INFO.class;
      container.appendChild(statusEl);
      DOM.statusElements.add(statusEl);

      const loginBtn = document.createElement('div');
      loginBtn.className = 'nodeimage-login-btn';
      loginBtn.textContent = '点击登录NodeImage';
      loginBtn.addEventListener('click', UI.openLogin);
      loginBtn.style.display = 'none';
      container.appendChild(loginBtn);
      DOM.loginButtons.add(loginBtn);

      UI.updateState();
    }
  };

  // ===== 图片处理 (Image Handling) =====
  // 负责处理用户通过粘贴、拖拽等方式提供的图片数据。
  const ImageHandler = {
    /**
     * 处理编辑器的粘贴事件。
     * @param {ClipboardEvent} e - 粘贴事件对象。
     */
    handlePaste: e => {
      // 检查是否在编辑器中进行操作
      if (!Utils.isEditingInEditor()) return;

      const dt = e.clipboardData || e.originalEvent?.clipboardData;
      if (!dt) return;

      let files = [];

      // 处理剪贴板中的文件
      if (dt.files && dt.files.length) {
        files = Array.from(dt.files).filter(f => f.type.startsWith('image/'));
      }
      // 处理剪贴板中的项目
      else if (dt.items && dt.items.length) {
        files = Array.from(dt.items)
          .filter(i => i.kind === 'file' && i.type.startsWith('image/'))
          .map(i => i.getAsFile())
          .filter(Boolean);
      }

      if (files.length) {
        e.preventDefault();
        e.stopPropagation();

        // 同步检查API密钥是否存在,无需异步等待
        if (!APP.api.key) {
          UI.openLogin();
          return;
        }

        ImageHandler.handleFiles(files);
      }
    },

    /**
     * 统一处理文件列表,过滤非图片文件并上传。
     * @param {File[]} files - 文件对象数组。
     */
    handleFiles: files => {
      if (!APP.api.key) {
        UI.openLogin();
        return;
      }

      files.filter(file => file?.type.startsWith('image/'))
        .forEach(ImageHandler.uploadAndInsert);
    },

    /**
     * 封装单个文件的上传、插入Markdown链接以及状态更新的完整流程。
     * @param {File} file - 要处理的图片文件。
     */
    uploadAndInsert: async file => {
      setStatus(STATUS.INFO.class, MESSAGE.UPLOADING);

      try {
        const result = await API.uploadImage(file);
        ImageHandler.insertMarkdown(result.markdown);

        await setStatus(STATUS.SUCCESS.class, MESSAGE.UPLOAD_SUCCESS, APP.statusTimeout);
      } catch (error) {
        if (error.message === MESSAGE.LOGIN_EXPIRED) {
          await Auth.checkLoginIfNeeded(true);
        }

        const errorMessage = `上传失败: ${error.message}`;
        console.error('[NodeImage]', error);

        await setStatus(STATUS.ERROR.class, errorMessage, APP.statusTimeout);
      }
    },

    /**
     * 将Markdown文本插入到CodeMirror编辑器中。
     * @param {string} markdown - 要插入的Markdown文本。
     */
    insertMarkdown: markdown => {
      const cm = DOM.getEditor();
      if (cm) {
        const cursor = cm.getCursor();
        cm.replaceRange(`\n${markdown}\n`, cursor);
      }
    }
  };

  // ===== 认证管理 (Authentication Management) =====
  // 核心模块,管理API Key的获取、存储、验证和清除。
  const Auth = {
    /**
     * 按需检查登录状态。如果已有Key且非强制检查,则直接返回true。
     * @param {boolean} [forceCheck=false] - 是否强制从服务器获取最新状态。
     * @returns {Promise<boolean>} - 用户是否已登录。
     */
    checkLoginIfNeeded: async (forceCheck = false) => {
      if (APP.api.key && !forceCheck) {
        return true;
      }

      const isLoggedIn = await API.checkLoginAndGetKey();

      if (!isLoggedIn && APP.api.key) {
        setStatus(STATUS.WARNING.class, MESSAGE.LOGIN_EXPIRED);
      }

      UI.updateState();

      return isLoggedIn;
    },

    /**
     * 检查由其他页面设置的登出标志,并清除本地认证信息。
     * 用于多标签页之间的状态同步。
     */
    checkLogoutFlag: () => {
      if (APP.storage.get('logout') === 'true') {
        APP.api.clearKey();
        APP.storage.remove('logout');
        setStatus(STATUS.WARNING.class, MESSAGE.LOGOUT);
      }
    },

    /**
     * 检查近期是否在NodeImage网站登录过。
     * 如果是,则主动获取一次API Key,以应对浏览器缓存可能导致的状态不一致。
     */
    checkRecentLogin: async () => {
      const lastLoginCheck = APP.storage.get('loginCheck');
      if (lastLoginCheck && (Date.now() - parseInt(lastLoginCheck) < APP.auth.recentLoginGracePeriod)) {
        await API.checkLoginAndGetKey();
        APP.storage.remove('loginCheck');
      }
    },

    /**
     * 设置storage事件监听器,实现跨标签页通信。
     * 当在NodeImage网站登录成功或登出时,可以通知NodeSeek页面的脚本。
     */
    setupStorageListener: () => {
      window.addEventListener('storage', event => {
        const { loginStatus, logout } = APP.storage.keys;

        if (event.key === loginStatus && event.newValue === 'login_success') {
          API.checkLoginAndGetKey();
          localStorage.removeItem(loginStatus);
        } else if (event.key === logout && event.newValue === 'true') {
          APP.api.clearKey();
          localStorage.removeItem(logout);
        }
      });
    },

    /**
     * 监听点击事件,以检测用户在NodeImage网站上的登出操作。
     */
    monitorLogout: () => {
      document.addEventListener('click', e => {
        // 优先使用ID和class进行精确匹配,回退到文本匹配
        const logoutButton = e.target.closest('#logoutBtn, .logout-btn');
        if (logoutButton || e.target.textContent?.match(/登出|注销|退出|logout|sign out/i)) {
          APP.storage.set('logout', 'true');
        }
      });
    },

    /**
     * 在NodeImage网站上轮询检查登录状态。
     * 登录成功后,通过localStorage通知其他页面,并自动关闭当前窗口。
     */
    startLoginStatusCheck: () => {
      const checkLoginInterval = setInterval(async () => {
        try {
          const isLoggedIn = await API.checkLoginAndGetKey();

          if (isLoggedIn) {
            clearInterval(checkLoginInterval);

            APP.storage.remove('loginStatus');
            APP.storage.set('loginStatus', 'login_success');
            APP.storage.set('loginCheck', Date.now().toString());
          }
        } catch (error) {}
      }, APP.auth.loginCheckInterval);

      setTimeout(() => clearInterval(checkLoginInterval), APP.auth.loginCheckTimeout);
    },

    /**
     * 处理在NodeImage网站上时的特定逻辑。
     * 主要负责登录流程的发起和登出状态的监控。
     */
    handleNodeImageSite: () => {
      if (['/login', '/register', '/'].includes(window.location.pathname)) {
        const loginForm = document.querySelector('form');
        if (loginForm) {
          loginForm.addEventListener('submit', () => {
            APP.storage.set('loginStatus', 'login_pending');
          });
        }

        // 只有当登录状态为"login_pending"时才启动登录检查
        if (APP.storage.get('loginStatus') === 'login_pending') {
          Auth.startLoginStatusCheck();
        }
      } else if (APP.storage.get('loginStatus') === 'login_pending') {
        Auth.checkLoginIfNeeded(true);
      }

      Auth.monitorLogout();
    }
  };

  // ===== 初始化 (Initialization) =====
  // 脚本的入口点和主逻辑流程。
  const init = async () => {
    // 如果在NodeImage网站,则执行特定的登录/登出辅助逻辑。
    if (Utils.isNodeImageSite()) {
      Auth.handleNodeImageSite();
      return;
    }

    // 在NodeSeek网站上的核心初始化流程
    // 1. 全局监听,捕获所有粘贴事件
    document.addEventListener('paste', ImageHandler.handlePaste);

    // 2. 页面重新获得焦点时,检查登录状态,确保信息最新
    window.addEventListener('focus', () => Auth.checkLoginIfNeeded());

    // 3. 异步等待编辑器加载完成,然后绑定拖拽上传事件
    Utils.waitForElement(SELECTORS.editor).then(editor => {
      DOM.editor = editor;

      // 编辑器级别不再绑定粘贴事件,因为已在document上全局处理
      editor.addEventListener('dragover', e => {
        e.preventDefault();
        e.dataTransfer.dropEffect = 'copy';
      });

      editor.addEventListener('drop', e => {
        e.preventDefault();
        ImageHandler.handleFiles(Array.from(e.dataTransfer.files));
      });
    });

    // 4. 异步等待工具栏加载,然后注入UI
    Utils.waitForElement(SELECTORS.toolbar).then(UI.setupToolbar);

    // 5. 使用MutationObserver监控DOM变化,以应对SPA页面切换导致的UI丢失问题
    const observer = new MutationObserver(() => {
      const toolbar = document.querySelector(SELECTORS.toolbar);
      if (toolbar && !toolbar.querySelector(SELECTORS.container)) {
        UI.setupToolbar(toolbar);
      }
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ['class', 'style']
    });

    // 6. 补充监听特定点击事件,兼容某些tab切换场景
    document.addEventListener('click', e => {
      if (e.target.closest('.tab-option')) {
        setTimeout(() => {
          const toolbar = document.querySelector(SELECTORS.toolbar);
          if (toolbar && !toolbar.querySelector(SELECTORS.container)) {
            UI.setupToolbar(toolbar);
          }
        }, 100);
      }
    });

    // 7. 启动时执行一系列认证状态检查
    Auth.checkLogoutFlag(); // 检查是否在其他页面登出
    Auth.setupStorageListener(); // 设置跨页面通信
    await Auth.checkRecentLogin(); // 检查是否刚在其他页面登录
    await Auth.checkLoginIfNeeded(); // 确保当前持有有效的API Key
  };

  // 脚本从load事件后开始执行
  window.addEventListener('load', init);
})();