为什么不把 node 图床接入编辑器。

小满 2026-09-24 23:52 1

每次都需要先去 nodeimage 传图片,再复制过来粘贴,有些麻烦啊?

佬们怎么用的,浏览器插件吗?

最新回复 (10)
  • 应无所住 09-24 23:53
    1楼

    https://www.nodeseek.com/post-926111-1

  • 忍者猫小七 09-24 23:53
    2楼

    直接挂猴上,复制粘贴就行

  • nanami814 09-24 23:54
    3楼

    Nodeimage原帖有提供插件,不内置主要是怕大改编辑器

  • 陌生人 09-24 23:54
    4楼

    nodeseek pro

  • ps3chinamj 09-24 23:54
    5楼

  • 小满 楼主 09-24 23:57
    6楼

    @应无所住 #1 感谢佬

  • 只眼姬 09-24 23:58
    7楼

    nodeseek pro

  • nobibi 09-24 23:59
    8楼

    // ==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

    // @downloadURL https://update.greasyfork.org/scripts/542011/NodeImage图片上传助手.user.js

    // @updateURL https://update.greasyfork.org/scripts/542011/NodeImage图片上传助手.meta.js

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

    })();

  • nobibi 09-24 23:59
    9楼

    给你发的油猴脚本

  • nobibi 09-25 00:00
    10楼

    只要复制图片,自动生成上传连接

* 帖子来源NodeSeek
返回