// ==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 = ``;
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 = ``;
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 = ``;
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 = ``;
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 = ``;
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');
}
}
}
})();