// ==UserScript== // @name NodeImage图片上传助手 (双站版) // @namespace https://www.nodeimage.com/ // @version 1.0.3 // @description 在NodeSeek和DeepFlood编辑器中粘贴图片自动上传到NodeImage图床 // @author shuai // @match *://www.nodeseek.com/* // @match *://nodeseek.com/* // @match *://www.deepflood.com/* // @match *://deepflood.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.3 * * This script integrates the NodeImage image hosting service with the NodeSeek * and DeepFlood websites' editors. It allows users to upload images by pasting, * dragging and dropping, or using a dedicated toolbar button. */ (() => { '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, loginCheckInterval: 3000, loginCheckTimeout: 300000 }, // 新增:支持的站点配置 supportedSites: { 'nodeseek.com': { editor: '.CodeMirror', toolbar: '.mde-toolbar', imgBtn: '.toolbar-item.i-icon.i-icon-pic[title="图片"]' }, 'deepflood.com': { editor: '.CodeMirror', toolbar: '.mde-toolbar', imgBtn: '.toolbar-item.i-icon.i-icon-pic[title="图片"]' } } }; // ===== 动态选择器 (Dynamic Selectors) ===== const SELECTORS = { get currentSite() { const hostname = window.location.hostname.replace('www.', ''); return APP.supportedSites[hostname] || APP.supportedSites['nodeseek.com']; }, get editor() { return this.currentSite.editor; }, get toolbar() { return this.currentSite.toolbar; }, get imgBtn() { return this.currentSite.imgBtn; }, container: '#nodeimage-toolbar-container' }; // ===== 状态常量 (Status Constants) ===== 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) ===== const DOM = { editor: null, statusElements: new Set(), loginButtons: new Set(), getEditor: () => DOM.editor?.CodeMirror }; // ===== 全局样式 (Global Styles) ===== 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 = { isNodeImageSite: () => /^(.*\.)?nodeimage\.com$/.test(window.location.hostname), // 新增:判断当前站点是否为支持的社区站点 isCommunitySite: () => { const hostname = window.location.hostname.replace('www.', ''); return Object.keys(APP.supportedSites).includes(hostname); }, 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 }); }), isEditingInEditor: () => { const a = document.activeElement; return a && (a.classList.contains('CodeMirror') || a.closest('.CodeMirror') || a.tagName === 'TEXTAREA'); }, createFileInput: cb => { const i = Object.assign(document.createElement('input'), { type: 'file', multiple: true, accept: 'image/*' }); i.onchange = e => cb([...e.target.files]); i.click(); }, delay: ms => new Promise(r => setTimeout(r, ms)) }; // ===== API通信 (API Communication) ===== const API = { 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 }); }); }, 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; } }, 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) ===== 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 = { 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 = ''; } }); }, openLogin: () => { APP.storage.set('loginStatus', 'login_pending'); window.open(APP.site.url, '_blank'); }, 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 = { 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(); if (!APP.api.key) { UI.openLogin(); return; } ImageHandler.handleFiles(files); } }, handleFiles: files => { if (!APP.api.key) { UI.openLogin(); return; } files.filter(file => file?.type.startsWith('image/')) .forEach(ImageHandler.uploadAndInsert); }, 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); } }, insertMarkdown: markdown => { const cm = DOM.getEditor(); if (cm) { const cursor = cm.getCursor(); cm.replaceRange(`\n${markdown}\n`, cursor); } } }; // ===== 认证管理 (Authentication Management) ===== const Auth = { 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); } }, checkRecentLogin: async () => { const lastLoginCheck = APP.storage.get('loginCheck'); if (lastLoginCheck && (Date.now() - parseInt(lastLoginCheck) < APP.auth.recentLoginGracePeriod)) { await API.checkLoginAndGetKey(); APP.storage.remove('loginCheck'); } }, 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); } }); }, monitorLogout: () => { document.addEventListener('click', e => { const logoutButton = e.target.closest('#logoutBtn, .logout-btn'); if (logoutButton || e.target.textContent?.match(/登出|注销|退出|logout|sign out/i)) { APP.storage.set('logout', 'true'); } }); }, 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); }, handleNodeImageSite: () => { if (['/login', '/register', '/'].includes(window.location.pathname)) { const loginForm = document.querySelector('form'); if (loginForm) { loginForm.addEventListener('submit', () => { APP.storage.set('loginStatus', '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; } // 如果在支持的社区站点,执行初始化逻辑 if (Utils.isCommunitySite()) { // 1. 全局监听粘贴事件 document.addEventListener('paste', ImageHandler.handlePaste); // 2. 页面获得焦点时检查登录状态 window.addEventListener('focus', () => Auth.checkLoginIfNeeded()); // 3. 等待编辑器加载并绑定拖拽事件 Utils.waitForElement(SELECTORS.editor).then(editor => { DOM.editor = editor; 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. 监控DOM变化应对SPA 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(); } }; // 脚本从load事件后开始执行 window.addEventListener('load', init); })(); 和Claude双排改了一下,好像能用了
问问ai
不开呗😄
加个match试试
和Claude双排改了一下,好像能用了