// ==UserScript== // @name NodeSeek 编辑器图床增强脚本 // @namespace http://tampermonkey.net/ // @version 2.3.1 // @description 在 NodeSeek 支持点击、拖拽和粘贴上传图片银星公益图床、16 图床兼容自建(兰空图床),并插入 Markdown 格式到编辑器 // @author ZhangBreeze // @match https://www.nodeseek.com/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @license MIT // @downloadURL none // ==/UserScript== (function() { 'use strict'; const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = 'image/*'; fileInput.multiple = true; fileInput.style.display = 'none'; document.body.appendChild(fileInput); const editorWrapper = document.querySelector('#cm-editor-wrapper'); const codeMirror = document.querySelector('.CodeMirror.cm-s-default.cm-s-nsk.CodeMirror-wrap.CodeMirror-overlayscroll'); const cmInstance = document.querySelector('.CodeMirror')?.CodeMirror; function addUploadHint(container) { if (!container) return; const existingHint = container.querySelector('.upload-hint-text'); if (existingHint) return; const hint = document.createElement('div'); hint.className = 'upload-hint-text'; hint.textContent = '支持拖拽或粘贴上传图片'; hint.style.position = 'absolute'; hint.style.bottom = '5px'; hint.style.right = '5px'; hint.style.color = '#888'; hint.style.fontSize = '12px'; hint.style.zIndex = '10'; hint.style.pointerEvents = 'none'; container.style.position = 'relative'; container.appendChild(hint); } if (editorWrapper) { addUploadHint(editorWrapper); } else if (codeMirror) { addUploadHint(codeMirror); } function showUploadHint(container, fileCount) { if (!container) return; const existingHints = document.querySelectorAll('[id^="upload-hint-"]'); existingHints.forEach(hint => hint.remove()); const uploadHint = document.createElement('div'); uploadHint.textContent = `正在上传 ${fileCount} 张图片,请稍等`; uploadHint.style.position = 'absolute'; uploadHint.style.top = '50%'; uploadHint.style.left = '50%'; uploadHint.style.transform = 'translate(-50%, -50%)'; uploadHint.style.color = '#666'; uploadHint.style.fontSize = '14px'; uploadHint.style.background = 'rgba(0, 0, 0, 0.1)'; uploadHint.style.padding = '5px 10px'; uploadHint.style.borderRadius = '3px'; uploadHint.style.zIndex = '20'; uploadHint.style.maxWidth = '80%'; uploadHint.style.whiteSpace = 'nowrap'; uploadHint.style.overflow = 'hidden'; uploadHint.style.textOverflow = 'ellipsis'; uploadHint.id = 'upload-hint-' + (container === editorWrapper ? 'wrapper' : 'codemirror'); container.appendChild(uploadHint); } function removeUploadHint(container) { const uploadHint = document.getElementById('upload-hint-' + (container === editorWrapper ? 'wrapper' : 'codemirror')); if (uploadHint) uploadHint.remove(); } function addSettingsIcon() { const uploadIcon = document.querySelector('span.toolbar-item.i-icon.i-icon-pic'); if (!uploadIcon) return; const existingSettingsIcon = uploadIcon.parentNode.querySelector('.settings-icon'); if (existingSettingsIcon) return; const settingsIcon = document.createElement('span'); settingsIcon.className = 'toolbar-item i-icon settings-icon'; settingsIcon.style.cursor = 'pointer'; settingsIcon.style.marginLeft = '5px'; settingsIcon.style.display = 'inline-block'; settingsIcon.style.verticalAlign = 'middle'; settingsIcon.style.width = '16px'; settingsIcon.style.height = '16px'; settingsIcon.title = '选择图床'; settingsIcon.innerHTML = ` `; uploadIcon.parentNode.insertBefore(settingsIcon, uploadIcon.nextSibling); settingsIcon.addEventListener('click', () => { showSettingsModal(); }); } function observeToolbar() { const targetNode = document.body; const config = { childList: true, subtree: true }; const callback = (mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { const uploadIcon = document.querySelector('span.toolbar-item.i-icon.i-icon-pic'); if (uploadIcon) { addSettingsIcon(); } } } }; const observer = new MutationObserver(callback); observer.observe(targetNode, config); // Initial check in case the toolbar is already present addSettingsIcon(); } observeToolbar(); function showSettingsModal() { const existingModal = document.querySelector('#image-host-settings-modal'); if (existingModal) existingModal.remove(); const modal = document.createElement('div'); modal.id = 'image-host-settings-modal'; modal.style.position = 'fixed'; modal.style.top = '50%'; modal.style.left = '50%'; modal.style.transform = 'translate(-50%, -50%)'; modal.style.background = 'linear-gradient(135deg, #ffffff, #f0f4f8)'; modal.style.padding = '25px'; modal.style.borderRadius = '12px'; modal.style.boxShadow = '0 4px 20px rgba(0,0,0,0.15)'; modal.style.zIndex = '1000'; modal.style.width = '350px'; modal.style.fontFamily = "'Segoe UI', Arial, sans-serif"; modal.style.color = '#333'; // 获取当前保存的设置 const currentHost = GM_getValue('imageHost', 'lankong'); const currentSixteenToken = GM_getValue('sixteenToken', ''); const currentLankongToken = GM_getValue('lankongCustomToken', ''); const currentLankongApi = GM_getValue('lankongCustomApi', ''); // 获取 Cloudflare ImgBed 设置 const currentCloudflareImgbedApi = GM_getValue('cloudflareImgbedApi'); const currentCloudflareImgbedAuthCode = GM_getValue('cloudflareImgbedAuthCode'); // 获取 Cloudflare ImgBed 压缩设置,默认勾选 (true) const currentCloudflareImgbedCompress = GM_getValue('cloudflareImgbedCompress', true); // <-- 默认值改为 true modal.innerHTML = `

图床设置

`; const overlay = document.createElement('div'); overlay.style.position = 'fixed'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.width = '100%'; overlay.style.height = '100%'; overlay.style.background = 'rgba(0,0,0,0.4)'; overlay.style.zIndex = '999'; document.body.appendChild(overlay); document.body.appendChild(modal); const hostSelect = document.querySelector('#image-host-select'); const lankongTokenSection = document.querySelector('#lankong-token-section'); const sixteenTokenSection = document.querySelector('#sixteen-token-section'); const cloudflareImgbedSection = document.querySelector('#cloudflare-imgbed-section'); // 获取 Cloudflare ImgBed 区域元素 // 修改下拉菜单的事件监听器 hostSelect.addEventListener('change', () => { lankongTokenSection.style.display = hostSelect.value === 'lankong-custom' ? 'block' : 'none'; sixteenTokenSection.style.display = hostSelect.value === 'sixteen' ? 'block' : 'none'; cloudflareImgbedSection.style.display = hostSelect.value === 'cloudflare-imgbed' ? 'block' : 'none'; // 控制新区域的显示/隐藏 }); document.querySelector('#save-settings-btn').addEventListener('click', () => { const selectedHost = hostSelect.value; GM_setValue('imageHost', selectedHost); // 保存当前选择的图床类型 // 保存各图床的特定设置 if (selectedHost === 'sixteen') { const sixteenTokenInput = document.querySelector('#sixteen-token-input').value; GM_setValue('sixteenToken', sixteenTokenInput); } else if (selectedHost === 'lankong-custom') { const lankongTokenInput = document.querySelector('#lankong-token-input').value; const lankongApiInput = document.querySelector('#lankong-api-input').value; GM_setValue('lankongCustomToken', lankongTokenInput); GM_setValue('lankongCustomApi', lankongApiInput); } else if (selectedHost === 'cloudflare-imgbed') { // 保存 Cloudflare ImgBed 设置 const cloudflareImgbedApiInput = document.querySelector('#cloudflare-imgbed-api-input').value; const cloudflareImgbedAuthInput = document.querySelector('#cloudflare-imgbed-auth-input').value; const cloudflareImgbedCompressCheckbox = document.querySelector('#cloudflare-imgbed-compress-checkbox').checked; // 获取压缩选项的状态 GM_setValue('cloudflareImgbedApi', cloudflareImgbedApiInput); GM_setValue('cloudflareImgbedAuthCode', cloudflareImgbedAuthInput); GM_setValue('cloudflareImgbedCompress', cloudflareImgbedCompressCheckbox); // 保存压缩选项的状态 } // 注意:对于 lankong 和 uhsea,没有额外需要保存的设置 modal.remove(); overlay.remove(); }); document.querySelector('#close-settings-btn').addEventListener('click', () => { modal.remove(); overlay.remove(); }); // Hover effects remain the same const saveBtn = document.querySelector('#save-settings-btn'); const closeBtn = document.querySelector('#close-settings-btn'); saveBtn.addEventListener('mouseover', () => { saveBtn.style.background = 'linear-gradient(90deg, #45a049, #4CAF50)'; }); saveBtn.addEventListener('mouseout', () => { saveBtn.style.background = 'linear-gradient(90deg, #4CAF50, #45a049)'; }); closeBtn.addEventListener('mouseover', () => { closeBtn.style.background = 'linear-gradient(90deg, #e53935, #f44336)'; }); closeBtn.addEventListener('mouseout', () => { closeBtn.style.background = 'linear-gradient(90deg, #f44336, #e53935)'; }); } let isUploading = false; document.addEventListener('click', function(e) { const target = e.target.closest('span.toolbar-item.i-icon.i-icon-pic'); if (target && !isUploading) { e.preventDefault(); e.stopPropagation(); fileInput.click(); } }, true); fileInput.addEventListener('change', function(e) { if (e.target.files && e.target.files.length > 0 && !isUploading) { isUploading = true; const files = Array.from(e.target.files); uploadMultipleFiles(files, editorWrapper || codeMirror).finally(() => { isUploading = false; fileInput.value = ''; }); } }); // Drag and drop handlers remain the same if (editorWrapper) { editorWrapper.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); if (!isUploading) editorWrapper.style.border = '2px dashed #000'; }); editorWrapper.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); editorWrapper.style.border = ''; }); editorWrapper.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); editorWrapper.style.border = ''; if (e.dataTransfer.files && e.dataTransfer.files.length > 0 && !isUploading) { isUploading = true; const files = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith('image/')); if (files.length > 0) { uploadMultipleFiles(files, editorWrapper).finally(() => isUploading = false); } else { isUploading = false; } } }); } // Paste handler remains the same // 只绑定一次 paste 事件,优先绑定到 editorWrapper,如果不存在则绑定到 codeMirror,避免重复上传 if (editorWrapper) { editorWrapper.addEventListener('paste', (e) => { const items = (e.clipboardData || e.originalEvent.clipboardData).items; const imageFiles = []; for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { const file = items[i].getAsFile(); if (file) imageFiles.push(file); } } if (imageFiles.length > 0 && !isUploading) { e.preventDefault(); isUploading = true; uploadMultipleFiles(imageFiles, editorWrapper).finally(() => isUploading = false); } }); } else if (codeMirror) { codeMirror.addEventListener('paste', (e) => { const items = (e.clipboardData || e.originalEvent.clipboardData).items; const imageFiles = []; for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { const file = items[i].getAsFile(); if (file) imageFiles.push(file); } } if (imageFiles.length > 0 && !isUploading) { e.preventDefault(); isUploading = true; uploadMultipleFiles(imageFiles, codeMirror).finally(() => isUploading = false); } }); } if (codeMirror) { codeMirror.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); if (!isUploading) codeMirror.style.border = '2px dashed #000'; }); codeMirror.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); codeMirror.style.border = ''; }); codeMirror.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); codeMirror.style.border = ''; if (e.dataTransfer.files && e.dataTransfer.files.length > 0 && !isUploading) { isUploading = true; const files = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith('image/')); if (files.length > 0) { uploadMultipleFiles(files, codeMirror).finally(() => isUploading = false); } else { isUploading = false; } } }); } async function uploadMultipleFiles(files, container) { if (files.length === 0) return; showUploadHint(container, files.length); const selectedHost = GM_getValue('imageHost', 'lankong'); // 获取当前选择的图床 const uploadPromises = files.map(file => { const formData = new FormData(); // 根据选择的图床调整 FormData 的文件字段名 // Cloudflare-ImgBed README 示例使用 'file' 字段名 if (selectedHost === 'lankong' || selectedHost === 'lankong-custom' || selectedHost === 'uhsea' || selectedHost === 'cloudflare-imgbed') { formData.append('file', file, file.name); } else { // 16 图床等可能使用 'image' formData.append('image', file, file.name); } return uploadToImageHost(formData, file.name, selectedHost); // 传递选中的图床类型 }); try { await Promise.all(uploadPromises); } catch (error) { console.error('批量上传失败:', error); alert('部分或全部图片上传失败,请查看控制台了解详情。'); // 错误提示 } finally { removeUploadHint(container); } } function uploadToImageHost(formData, fileName, host) { return new Promise((resolve, reject) => { const selectedHost = host; // 使用传入的host参数 let apiUrl, headers = {}; // 初始化headers为空对象 if (selectedHost === 'cloudflare-imgbed') { // 处理 Cloudflare ImgBed 选项 const baseApiUrl = GM_getValue('cloudflareImgbedApi', '').trim(); const authCode = GM_getValue('cloudflareImgbedAuthCode', '').trim(); // 获取压缩选项的状态,默认值也改为 true const enableCompress = GM_getValue('cloudflareImgbedCompress', true); // <-- 默认值改为 true if (!baseApiUrl) { console.error('Cloudflare ImgBed 需要设置域名'); reject(new Error('Cloudflare ImgBed 需要设置域名')); return; } if (!authCode) { console.error('Cloudflare ImgBed 需要设置 Auth Code'); reject(new Error('Cloudflare ImgBed 需要设置 Auth Code')); return; } // 根据 README 示例构建上传 URL,authCode 作为查询参数 // 确保 baseApiUrl 不以斜杠结尾,除非它就是根路径 "/" const cleanedBaseUrl = baseApiUrl.endsWith('/') && baseApiUrl !== '/' ? baseApiUrl.slice(0, -1) : baseApiUrl; apiUrl = `${cleanedBaseUrl}/upload?authCode=${encodeURIComponent(authCode)}`; // 始终添加 serverCompress 参数,值为 enableCompress 的布尔值字符串形式 apiUrl += '&serverCompress=' + enableCompress; // <-- 始终添加参数,值为 true 或 false // 根据 README 示例,不需要 Authorization 头 // headers = {}; // Already initialized // Cloudflare ImgBed 的上传逻辑 GM_xmlhttpRequest({ method: 'POST', url: apiUrl, headers: headers, // 使用空的headers对象 data: formData, onload: (response) => { try { // 根据你提供的 README 示例解析响应 [ { "src": "/file/..." } ] const jsonResponse = JSON.parse(response.responseText); if (response.status >= 200 && response.status < 300 && Array.isArray(jsonResponse) && jsonResponse.length > 0 && jsonResponse[0].src) { // 拼接完整的图片 URL: 域名 + src 路径 const imageUrl = cleanedBaseUrl + jsonResponse[0].src; const markdownImage = `![${fileName.split('.').slice(0, -1).join('.')}](${imageUrl})`; console.log('Cloudflare-ImgBed 上传成功,Markdown:', markdownImage); insertToEditor(markdownImage); resolve(); } else { console.error('Cloudflare-ImgBed 上传成功但返回格式无效或失败:', jsonResponse); // 尝试显示服务器返回的错误信息,虽然示例没有明确错误格式,但以防万一 const errorMessage = jsonResponse && (jsonResponse.message || jsonResponse.error || JSON.stringify(jsonResponse)); reject(new Error(`上传失败:服务器返回无效响应或错误 (${response.status}): ${errorMessage}`)); } } catch (error) { console.error('解析 Cloudflare-ImgBed 响应错误:', error); reject(new Error(`解析服务器响应失败: ${error.message}`)); } }, onerror: (error) => { console.error('Cloudflare-ImgBed 上传错误详情:', error); reject(new Error(`上传请求失败: ${error.statusText || error.message || JSON.stringify(error)}`)); }, ontimeout: () => { console.error('Cloudflare-ImgBed 请求超时'); reject(new Error('上传请求超时')); }, timeout: 30000 // 适当增加超时时间 }); } else if (selectedHost === 'lankong') { apiUrl = 'https://img.sss.wiki/api/index.php'; headers = {}; // 不需要特殊头 // 字段名改为 image,移除 file 字段 if (formData.has('file')) { const file = formData.get('file'); formData.delete('file'); formData.append('image', file, file.name); } // 固定 token formData.append('token', '7e2314b2b32aaf146c0bdfa460c1784b'); GM_xmlhttpRequest({ method: 'POST', url: apiUrl, headers: headers, data: formData, timeout: 10000, onload: (response) => { try { const jsonResponse = JSON.parse(response.responseText); // 假设返回格式为 { url: "图片链接" } if (response.status === 200 && jsonResponse && jsonResponse.url) { const imageUrl = jsonResponse.url; const markdownImage = `![${fileName.split('.').slice(0, -1).join('.')}](${imageUrl})`; console.log('银星图床上传成功,Markdown:', markdownImage); insertToEditor(markdownImage); resolve(); } else { console.error('银星图床上传成功但未获取到有效链接:', jsonResponse); reject(new Error('Invalid response from 银星图床')); } } catch (error) { console.error('解析银星图床响应错误:', error); reject(error); } }, onerror: (error) => { console.error('银星图床上传错误详情:', error); reject(error); }, ontimeout: () => { console.error('银星图床请求超时'); reject(new Error('Timeout')); } }); } else if (selectedHost === 'lankong-custom') { const api = GM_getValue('lankongCustomApi', '').trim(); const token = GM_getValue('lankongCustomToken', '').trim(); if (!api) { console.error('兰空图床需要设置 API 端点'); reject(new Error('兰空图床需要设置 API 端点')); return; } if (!token) { console.error('兰空图床需要设置 Token'); reject(new Error('兰空图床需要设置 Token')); return; } apiUrl = api; headers = { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' }; // 原有 Lankong Custom 上传逻辑 GM_xmlhttpRequest({ method: 'POST', url: apiUrl, headers: headers, data: formData, timeout: 10000, onload: (response) => { try { const jsonResponse = JSON.parse(response.responseText); if (response.status === 200 && jsonResponse && jsonResponse.data && jsonResponse.data.links && jsonResponse.data.links.url) { const imageUrl = jsonResponse.data.links.url; const markdownImage = `![${fileName.split('.').slice(0, -1).join('.')}](${imageUrl})`; console.log('兰空图床上传成功,Markdown:', markdownImage); insertToEditor(markdownImage); resolve(); } else { console.error('兰空图床上传成功但未获取到有效链接:', jsonResponse); reject(new Error('Invalid response from 兰空图床')); } } catch (error) { console.error('解析兰空图床响应错误:', error); reject(error); } }, onerror: (error) => { console.error('兰空图床上传错误详情:', error); reject(error); }, ontimeout: () => { console.error('兰空图床请求超时'); reject(new Error('Timeout')); } }); } else if (selectedHost === 'uhsea') { apiUrl = 'https://uhsea.com/Frontend/upload'; headers = {}; // Uhsea 可能不需要特定头 // 原有 Uhsea 上传逻辑 GM_xmlhttpRequest({ method: 'POST', url: apiUrl, headers: headers, data: formData, timeout: 10000, onload: (response) => { try { const jsonResponse = JSON.parse(response.responseText); if (response.status === 200 && jsonResponse && jsonResponse.data) { const imageUrl = jsonResponse.data; const markdownImage = `![${fileName.split('.').slice(0, -1).join('.')}](${imageUrl})`; console.log('屋舍图床上传成功,Markdown:', markdownImage); insertToEditor(markdownImage); resolve(); } else { console.error('屋舍图床上传成功但未获取到有效链接:', jsonResponse); reject(new Error('Invalid response from Uhsea')); } } catch (error) { console.error('解析屋舍图床响应错误:', error); reject(error); } }, onerror: (error) => { console.error('屋舍图床上传错误详情:', error); reject(error); }, ontimeout: () => { console.error('屋舍图床请求超时'); reject(new Error('Timeout')); } }); } else if (selectedHost === 'sixteen') { apiUrl = 'https://i.111666.best/image'; // 16 图床的上传地址 const token = GM_getValue('sixteenToken', '').trim(); if (!token) { console.error('16 图床需要设置 Auth-Token'); reject(new Error('16 图床需要设置 Auth-Token')); return; } headers = { 'Auth-Token': token }; // 原有 16 图床上传逻辑 GM_xmlhttpRequest({ method: 'POST', url: apiUrl, headers: headers, data: formData, timeout: 10000, onload: (response) => { try { if (response.status === 200 && response.responseText) { const jsonResponse = JSON.parse(response.responseText); if (jsonResponse.ok && jsonResponse.src) { // 16图床返回的src是路径,需要拼接域名 const imageUrl = `https://i.111666.best${jsonResponse.src}`; const markdownImage = `![${fileName.split('.').slice(0, -1).join('.')}](${imageUrl})`; console.log('16 图床上传成功,Markdown:', markdownImage); insertToEditor(markdownImage); resolve(); } else { console.error('16 图床返回的响应无效:', jsonResponse); reject(new Error('Invalid response from 16 图床')); } } else { console.error('16 图床上传失败:', response.responseText); reject(new Error(`Upload failed on 16 图床: ${response.status} ${response.statusText}`)); } } catch (error) { console.error('解析 16 图床响应错误:', error); reject(error); } }, onerror: (error) => { console.error('16 图床上传错误详情:', error); reject(error); }, ontimeout: () => { console.error('16 图床请求超时'); reject(new Error('Timeout')); } }); } // 可以根据需要添加更多的 else if 来支持其他图床类型 else { console.error(`未知的图床选项: ${selectedHost}`); reject(new Error(`未知的图床选项: ${selectedHost}`)); } }); } function insertToEditor(markdown) { if (cmInstance) { const cursor = cmInstance.getCursor(); cmInstance.replaceRange(markdown + '\n', cursor); console.log('已插入 Markdown 到编辑器'); } else { const editable = document.querySelector('.CodeMirror textarea') || document.querySelector('textarea'); if (editable) { const start = editable.selectionStart; const end = editable.selectionEnd; editable.value = editable.value.substring(0, start) + markdown + '\n' + editable.value.substring(end); editable.selectionStart = editable.selectionEnd = start + markdown.length + 1; console.log('已插入 Markdown 到 textarea'); const event = new Event('input', { bubbles: true }); editable.dispatchEvent(event); } else { console.error('未找到可编辑的 CodeMirror 实例或 textarea'); } } } })();