Greasy Fork

Greasy Fork is available in English.

粘贴图片自动上传图床

检测粘贴的图片,自动上传至SkyImg并根据域名配置返回不同格式的链接

当前为 2025-03-18 提交的版本,查看 最新版本

// ==UserScript==
// @name         粘贴图片自动上传图床
// @namespace    https://skyimg.de/
// @version      1.2.3
// @license      MIT
// @author       skyimg.de
// @connect      skyimg.de
// @description  检测粘贴的图片,自动上传至SkyImg并根据域名配置返回不同格式的链接
// @match        *://*/*
// @grant        GM_notification
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    // 全局配置参数
    const config = {
        // 上传 API 接口地址
        apiUrl: 'https://skyimg.de/api/upload',
        // 是否转换为 WebP 格式
        webp: true,
        notificationDuration: {
            info: 3000,
            success: 3500,
            warning: 5000,
            error: 5000
        },
        // 链接格式配置
        linkFormats: {
            url: 'URL格式',        // 纯 URL 链接
            md: 'Markdown格式',    // Markdown 格式: ![image](URL)
            bbc: 'BBCode格式'      // BBCode 格式: [img]URL[/img]
        }
    };

    // 链接格式设置,默认使用 Markdown 格式
    let domainFormatSettings = GM_getValue('domainFormatSettings', {
        'default': 'md'
    });
    // Token 设置
    let uploadToken = GM_getValue('uploadToken', '');
    // 排除网址设置,默认包含常见不适用的网站
    let exclusionList = GM_getValue('excludeSites', ["*.google.com", "*.bing.com", "*.skyimg.de", "*.baidu.com", "*.youtube.com", "*.x.com", "*.instagram.com", "*.facebook.com", "*.linux.do", "*.douyin.com", "*.*.*.*"]);

    GM_registerMenuCommand('配置链接格式', showFormatSettingsDialog);
    GM_registerMenuCommand('配置上传 Token', showTokenSettingsDialog);
    GM_registerMenuCommand('配置排除网址', showExclusionSettingsDialog);

    // 若当前站点匹配排除规则,则主功能不启用(但菜单命令依然可用)
    if (isCurrentSiteExcluded()) {
        console.log("当前网站匹配排除规则,图片上传功能不启用。");
        return;
    }

    // 监听粘贴事件
    document.addEventListener('paste', function(e) {
        // 获取剪贴板数据
        const clipboardData = e.clipboardData || window.clipboardData;
        if (!clipboardData) return;

        // 遍历剪贴板项,查找是否存在图像数据
        const items = clipboardData.items;
        let imageFile = null;
        for (let i = 0; i < items.length; i++) {
            if (items[i].type.indexOf('image') !== -1) {
                imageFile = items[i].getAsFile();
                break;
            }
        }

        // 如果剪贴板中没有图像,则返回不处理
        if (!imageFile) return;

        // 判断当前光标是否位于有效的输入区域中
        const activeElement = document.activeElement;
        if (!isValidInputField(activeElement)) {
            showToast('当前不在发帖界面,无法上传图片', 'warning');
            return;
        }

        // 阻止默认粘贴行为
        e.preventDefault();
        e.stopPropagation();

        // 上传图片至 API
        uploadImage(imageFile, activeElement);
    });

    /**
     * 检查当前网址是否符合排除规则
     * @returns {boolean}
     */
    function isCurrentSiteExcluded() {
        const hostname = window.location.hostname;
        for (let pattern of exclusionList) {
            const regexStr = '^' + pattern.replace(/[-\/\\^$+?.()|[\]{}]/g, '\\$&').replace(/\\\*/g, '.*') + '$';
            const regex = new RegExp(regexStr);
            if (regex.test(hostname)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 配置对话框:链接格式设置
     */
    function showFormatSettingsDialog() {
        const dialog = document.createElement('div');
        dialog.style.position = 'fixed';
        dialog.style.top = '50%';
        dialog.style.left = '50%';
        dialog.style.transform = 'translate(-50%, -50%)';
        dialog.style.backgroundColor = '#fff';
        dialog.style.padding = '20px';
        dialog.style.borderRadius = '8px';
        dialog.style.boxShadow = '0 4px 23px 0 rgba(0, 0, 0, 0.2)';
        dialog.style.maxWidth = '500px';
        dialog.style.width = '90%';
        dialog.style.maxHeight = '80vh';
        dialog.style.overflowY = 'auto';
        dialog.style.zIndex = '10000';
        dialog.style.fontFamily = 'Arial, sans-serif';

        // 对话框标题
        const title = document.createElement('h2');
        title.textContent = '配置链接格式';
        title.style.margin = '0 0 15px 0';
        title.style.color = '#333';
        dialog.appendChild(title);

        // 说明文本
        const desc = document.createElement('p');
        desc.textContent = '为不同域名配置粘贴图片后生成的链接格式。不在列表中的域名将使用默认格式。域名支持通配符匹配,例如:*.example.com';
        desc.style.marginBottom = '15px';
        desc.style.color = '#666';
        dialog.appendChild(desc);

        // 默认格式设置
        const defaultSection = document.createElement('div');
        defaultSection.style.marginBottom = '20px';
        defaultSection.style.padding = '10px';
        defaultSection.style.backgroundColor = '#f5f5f5';
        defaultSection.style.borderRadius = '4px';

        const defaultLabel = document.createElement('label');
        defaultLabel.textContent = '默认格式:';
        defaultLabel.style.fontWeight = 'bold';
        defaultLabel.style.display = 'block';
        defaultLabel.style.marginBottom = '5px';
        defaultSection.appendChild(defaultLabel);

        const defaultSelect = document.createElement('select');
        defaultSelect.style.width = '100%';
        defaultSelect.style.padding = '8px';
        defaultSelect.style.borderRadius = '4px';
        defaultSelect.style.border = '1px solid #ddd';

        for (const [value, text] of Object.entries(config.linkFormats)) {
            const option = document.createElement('option');
            option.value = value;
            option.textContent = text;
            if (domainFormatSettings['default'] === value) {
                option.selected = true;
            }
            defaultSelect.appendChild(option);
        }
        defaultSection.appendChild(defaultSelect);
        dialog.appendChild(defaultSection);

        // 现有域名配置列表
        const domainList = document.createElement('div');
        domainList.style.marginBottom = '20px';
        Object.entries(domainFormatSettings).forEach(([domain, format]) => {
            if (domain !== 'default') {
                const domainRow = createDomainRow(domain, format);
                domainList.appendChild(domainRow);
            }
        });
        dialog.appendChild(domainList);

        const addButton = document.createElement('button');
        addButton.textContent = '添加新域名';
        addButton.style.backgroundColor = '#4CAF50';
        addButton.style.color = 'white';
        addButton.style.border = 'none';
        addButton.style.padding = '10px 15px';
        addButton.style.borderRadius = '4px';
        addButton.style.cursor = 'pointer';
        addButton.style.marginRight = '10px';
        addButton.addEventListener('click', () => {
            const newDomain = prompt('请输入域名(例如:example.com 或 *.example.com):');
            if (newDomain && newDomain.trim() !== '' && newDomain !== 'default') {
                if (!domainFormatSettings[newDomain]) {
                    domainFormatSettings[newDomain] = domainFormatSettings['default'];
                    const domainRow = createDomainRow(newDomain, domainFormatSettings[newDomain]);
                    domainList.appendChild(domainRow);
                } else {
                    alert('该域名已存在!');
                }
            }
        });
        dialog.appendChild(addButton);

        // 保存按钮
        const saveButton = document.createElement('button');
        saveButton.textContent = '保存设置';
        saveButton.style.backgroundColor = '#2196F3';
        saveButton.style.color = 'white';
        saveButton.style.border = 'none';
        saveButton.style.padding = '10px 15px';
        saveButton.style.borderRadius = '4px';
        saveButton.style.cursor = 'pointer';
        saveButton.addEventListener('click', () => {
            domainFormatSettings['default'] = defaultSelect.value;
            GM_setValue('domainFormatSettings', domainFormatSettings);
            document.body.removeChild(overlay);
            showToast('设置已保存', 'success');
        });
        dialog.appendChild(saveButton);

        function createDomainRow(domain, format) {
            const row = document.createElement('div');
            row.style.display = 'flex';
            row.style.alignItems = 'center';
            row.style.marginBottom = '10px';
            row.style.padding = '10px';
            row.style.backgroundColor = '#f9f9f9';
            row.style.borderRadius = '4px';

            const domainText = document.createElement('div');
            domainText.textContent = domain;
            domainText.style.flexGrow = '1';
            domainText.style.marginRight = '10px';
            row.appendChild(domainText);

            const formatSelect = document.createElement('select');
            formatSelect.style.padding = '5px';
            formatSelect.style.marginRight = '10px';
            formatSelect.style.borderRadius = '4px';
            formatSelect.style.border = '1px solid #ddd';

            for (const [value, text] of Object.entries(config.linkFormats)) {
                const option = document.createElement('option');
                option.value = value;
                option.textContent = text;
                if (format === value) {
                    option.selected = true;
                }
                formatSelect.appendChild(option);
            }

            formatSelect.addEventListener('change', () => {
                domainFormatSettings[domain] = formatSelect.value;
            });
            row.appendChild(formatSelect);

            const deleteBtn = document.createElement('button');
            deleteBtn.textContent = '删除';
            deleteBtn.style.backgroundColor = '#F44336';
            deleteBtn.style.color = 'white';
            deleteBtn.style.border = 'none';
            deleteBtn.style.padding = '5px 10px';
            deleteBtn.style.borderRadius = '4px';
            deleteBtn.style.cursor = 'pointer';
            deleteBtn.addEventListener('click', () => {
                delete domainFormatSettings[domain];
                row.remove();
            });
            row.appendChild(deleteBtn);

            return row;
        }

        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.backgroundColor = 'rgba(0, 0, 0, 0.5)';
        overlay.style.zIndex = '9999';
        overlay.appendChild(dialog);
        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) {
                document.body.removeChild(overlay);
            }
        });

        document.body.appendChild(overlay);
    }

    /**
     * 配置对话框:上传 Token 设置
     */
    function showTokenSettingsDialog() {
        const dialog = document.createElement('div');
        dialog.style.position = 'fixed';
        dialog.style.top = '50%';
        dialog.style.left = '50%';
        dialog.style.transform = 'translate(-50%, -50%)';
        dialog.style.backgroundColor = '#fff';
        dialog.style.padding = '20px';
        dialog.style.borderRadius = '8px';
        dialog.style.boxShadow = '0 4px 23px 0 rgba(0, 0, 0, 0.2)';
        dialog.style.maxWidth = '400px';
        dialog.style.width = '80%';
        dialog.style.zIndex = '10000';
        dialog.style.fontFamily = 'Arial, sans-serif';

        const title = document.createElement('h2');
        title.textContent = '配置上传 Token';
        title.style.margin = '0 0 15px 0';
        title.style.color = '#333';
        dialog.appendChild(title);

        const desc = document.createElement('p');
        desc.textContent = '请输入用于云端同步的 Token(64 位字母数字,留空则不使用)。可前往 skyimg.de 网站获取';
        desc.style.marginBottom = '15px';
        desc.style.color = '#666';
        dialog.appendChild(desc);

        const tokenInput = document.createElement('input');
        tokenInput.type = 'text';
        tokenInput.placeholder = 'Token';
        tokenInput.value = uploadToken;
        tokenInput.style.width = 'calc(100% - 20px)';
        tokenInput.style.padding = '8px';
        tokenInput.style.marginBottom = '15px';
        tokenInput.style.borderRadius = '4px';
        tokenInput.style.border = '1px solid #ddd';
        dialog.appendChild(tokenInput);

        const saveButton = document.createElement('button');
        saveButton.textContent = '保存';
        saveButton.style.backgroundColor = '#2196F3';
        saveButton.style.color = 'white';
        saveButton.style.border = 'none';
        saveButton.style.padding = '10px 15px';
        saveButton.style.borderRadius = '4px';
        saveButton.style.cursor = 'pointer';
        saveButton.addEventListener('click', function() {
            const tokenVal = tokenInput.value.trim();
            if (tokenVal !== '' && !/^[A-Za-z0-9]{64}$/.test(tokenVal)) {
                alert('Token 格式不正确,必须是 64 位的字母数字');
                return;
            }
            uploadToken = tokenVal;
            GM_setValue('uploadToken', uploadToken);
            document.body.removeChild(overlay);
            showToast('Token 已保存', 'success');
        });
        dialog.appendChild(saveButton);

        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.backgroundColor = 'rgba(0, 0, 0, 0.5)';
        overlay.style.zIndex = '9999';
        overlay.appendChild(dialog);
        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) {
                document.body.removeChild(overlay);
            }
        });

        document.body.appendChild(overlay);
    }

    /**
     * 配置对话框:排除网址设置
     */
    function showExclusionSettingsDialog() {
        const dialog = document.createElement('div');
        dialog.style.position = 'fixed';
        dialog.style.top = '50%';
        dialog.style.left = '50%';
        dialog.style.width = '500px';
        dialog.style.marginLeft = '-250px';
        dialog.style.marginTop = '-200px';
        dialog.style.backgroundColor = '#ffffff';
        dialog.style.padding = '20px';
        dialog.style.borderRadius = '8px';
        dialog.style.boxShadow = '0 4px 20px rgba(0, 0, 0, 0.2)';
        dialog.style.zIndex = '10000';
        dialog.style.fontFamily = 'Arial, sans-serif';
        dialog.style.color = '#333333';

        // 标题
        const title = document.createElement('h2');
        title.textContent = '配置排除网址';
        title.style.margin = '0 0 15px 0';
        title.style.fontSize = '18px';
        title.style.fontWeight = 'bold';
        title.style.color = '#333333';
        dialog.appendChild(title);

        // 说明文本
        const desc = document.createElement('p');
        desc.textContent = '请在下方文本框中输入网址规则,每行一个规则(支持通配符,如:*.example.com)';
        desc.style.marginBottom = '15px';
        desc.style.fontSize = '14px';
        desc.style.lineHeight = '1.4';
        desc.style.color = '#666666';
        dialog.appendChild(desc);

        const textareaContainer = document.createElement('div');
        textareaContainer.style.position = 'relative';
        textareaContainer.style.width = '100%';
        textareaContainer.style.height = '200px';
        textareaContainer.style.marginBottom = '15px';
        textareaContainer.style.border = '1px solid #cccccc';
        textareaContainer.style.borderRadius = '4px';
        textareaContainer.style.overflow = 'hidden';
        dialog.appendChild(textareaContainer);

        // 文本区域
        const textarea = document.createElement('textarea');
        textarea.style.position = 'absolute';
        textarea.style.top = '0';
        textarea.style.left = '0';
        textarea.style.width = '100%';
        textarea.style.height = '100%';
        textarea.style.padding = '10px';
        textarea.style.boxSizing = 'border-box';
        textarea.style.border = 'none';
        textarea.style.outline = 'none';
        textarea.style.resize = 'none';
        textarea.style.fontFamily = 'Consolas, Monaco, "Courier New", monospace';
        textarea.style.fontSize = '14px';
        textarea.style.lineHeight = '1.5';
        textarea.style.color = '#000000';
        textarea.style.WebkitFontSmoothing = 'auto';
        textarea.style.MozOsxFontSmoothing = 'auto';
        textarea.style.transition = 'none';
        textarea.style.textAlign = 'left';
        textarea.value = exclusionList.join('\n');
        textareaContainer.appendChild(textarea);

        const btnContainer = document.createElement('div');
        btnContainer.style.display = 'flex';
        btnContainer.style.justifyContent = 'flex-end';
        btnContainer.style.gap = '10px';
        btnContainer.style.marginTop = '15px';
        dialog.appendChild(btnContainer);

        const saveButton = document.createElement('button');
        saveButton.textContent = '保存设置';
        saveButton.style.padding = '8px 16px';
        saveButton.style.backgroundColor = '#2196F3';
        saveButton.style.color = '#ffffff';
        saveButton.style.border = 'none';
        saveButton.style.borderRadius = '4px';
        saveButton.style.cursor = 'pointer';
        saveButton.style.fontSize = '14px';
        saveButton.style.fontWeight = 'normal';
        saveButton.addEventListener('click', () => {
            let newRules = textarea.value.split('\n').map(rule => rule.trim()).filter(rule => rule !== '');
            exclusionList = newRules;
            GM_setValue('excludeSites', exclusionList);
            document.body.removeChild(overlay);
            showToast('排除网址设置已保存', 'success');
        });
        btnContainer.appendChild(saveButton);

        const cancelButton = document.createElement('button');
        cancelButton.textContent = '取消';
        cancelButton.style.padding = '8px 16px';
        cancelButton.style.backgroundColor = '#F44336';
        cancelButton.style.color = '#ffffff';
        cancelButton.style.border = 'none';
        cancelButton.style.borderRadius = '4px';
        cancelButton.style.cursor = 'pointer';
        cancelButton.style.fontSize = '14px';
        cancelButton.style.fontWeight = 'normal';
        cancelButton.addEventListener('click', () => {
            document.body.removeChild(overlay);
        });
        btnContainer.appendChild(cancelButton);

        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.backgroundColor = 'rgba(0, 0, 0, 0.5)';
        overlay.style.zIndex = '9999';
        overlay.appendChild(dialog);
        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) {
                document.body.removeChild(overlay);
            }
        });

        document.body.appendChild(overlay);

        // 加载后让textarea获得焦点
        setTimeout(() => textarea.focus(), 100);
    }

    /**
     * 上传图片到 API 并处理响应
     * @param {File} file - 上传的图片文件
     * @param {Element} targetElement - 要插入链接的目标输入框
     */
    function uploadImage(file, targetElement) {
        showToast('正在上传图片...', 'info');

        const formData = new FormData();
        formData.append('file', file);

        // 根据配置决定是否调用 webp 转换
        const url = config.webp ? `${config.apiUrl}?webp=true` : config.apiUrl;

        GM_xmlhttpRequest({
            method: 'POST',
            url: url,
            data: formData,
            responseType: 'json',
            headers: (uploadToken && /^[A-Za-z0-9]{64}$/.test(uploadToken))
                ? { 'x-sync-token': uploadToken }
                : {},
            onload: function(response) {
                handleUploadResponse(response, targetElement);
            },
            onerror: function(error) {
                console.error('上传失败:', error);
                showToast('图片上传失败,请重试', 'error');
            }
        });
    }

    /**
     * 处理上传响应,生成相应格式的图片链接并插入目标输入框
     */
    function handleUploadResponse(response, targetElement) {
        if (response.status !== 200 || !response.response || !Array.isArray(response.response) || response.response.length === 0) {
            showToast('图片上传失败:服务器响应异常', 'error');
            return;
        }

        const imageData = response.response[0];
        if (!imageData.url) {
            showToast('图片上传失败:响应数据不完整', 'error');
            return;
        }

        // 根据当前域名及配置决定链接格式(支持通配符匹配)
        const currentDomain = window.location.hostname;
        let formatType = domainFormatSettings[currentDomain];
        if (!formatType) {
            for (const key in domainFormatSettings) {
                if (key === 'default') continue;
                if (key.indexOf('*') !== -1) {
                    const escaped = key.replace(/[-\/\\^$+?.()|[\]{}]/g, '\\$&');
                    const pattern = '^' + escaped.replace(/\*/g, '.*') + '$';
                    const regex = new RegExp(pattern);
                    if (regex.test(currentDomain)) {
                        formatType = domainFormatSettings[key];
                        break;
                    }
                }
            }
        }
        formatType = formatType || domainFormatSettings['default'];

        const imageUrl = imageData.url;
        let formattedLink;

        switch(formatType) {
            case 'url':
                formattedLink = imageUrl;
                break;
            case 'bbc':
                formattedLink = `[img]${imageUrl}[/img]`;
                break;
            case 'md':
            default:
                formattedLink = `![image](${imageUrl})`;
                break;
        }

        // 将生成的链接插入到目标输入框内
        insertTextToElement(targetElement, formattedLink);
        showToast('图片上传成功!', 'success');
    }

    /**
     * 向目标元素插入文本
     */
    function insertTextToElement(element, text) {
        if (!element) return;

        // 针对标准输入框处理
        if (element.tagName === 'TEXTAREA' || element.tagName === 'INPUT') {
            const startPos = element.selectionStart || 0;
            const endPos = element.selectionEnd || startPos;
            const beforeText = element.value.substring(0, startPos);
            const afterText = element.value.substring(endPos);

            element.value = beforeText + text + afterText;

            const newCursorPos = startPos + text.length;
            element.selectionStart = newCursorPos;
            element.selectionEnd = newCursorPos;
            element.focus();
            return;
        }

        // 针对 contenteditable 元素处理
        if (element.getAttribute('contenteditable') === 'true') {
            document.execCommand('insertText', false, text);
            return;
        }

        // 针对 CodeMirror 编辑器处理
        const codeMirrorLine = element.closest('.CodeMirror-line') || element;
        if (codeMirrorLine.classList.contains('CodeMirror-line') || element.closest('.CodeMirror')) {
            const codeMirrorElement = element.closest('.CodeMirror');
            if (codeMirrorElement && codeMirrorElement.CodeMirror) {
                const cm = codeMirrorElement.CodeMirror;
                const doc = cm.getDoc();
                const cursor = doc.getCursor();
                doc.replaceRange(text, cursor);
            } else {
                try {
                    document.execCommand('insertText', false, text);
                } catch (e) {
                    console.error('无法插入文本:', e);
                    showToast('无法自动插入图片链接,请手动复制', 'warning');
                    copyToClipboard(text);
                }
            }
            return;
        }

        // 其他情况下,尝试 execCommand 插入文本,否则复制到剪贴板
        try {
            document.execCommand('insertText', false, text);
        } catch (e) {
            showToast('无法自动插入图片链接,已复制到剪贴板', 'warning');
            copyToClipboard(text);
        }
    }

    /**
     * 将文本复制到剪贴板
     */
    function copyToClipboard(text) {
        const textarea = document.createElement('textarea');
        textarea.value = text;
        textarea.style.position = 'fixed';
        textarea.style.opacity = '0';
        document.body.appendChild(textarea);
        textarea.select();
        document.execCommand('copy');
        document.body.removeChild(textarea);
    }

    /**
     * 使用 Toast 显示通知(若 GM_notification 可用,则优先使用)
     */
    function showToast(message, type = 'info') {
        const duration = config.notificationDuration[type] || 3000;

        if (typeof GM_notification !== 'undefined') {
            GM_notification({
                text: message,
                title: '图片上传',
                timeout: duration
            });
            return;
        }

        let toast = document.getElementById('skyimg-toast');

        if (!toast) {
            toast = document.createElement('div');
            toast.id = 'skyimg-toast';
            toast.style.position = 'fixed';
            toast.style.bottom = '20px';
            toast.style.right = '20px';
            toast.style.padding = '10px 15px';
            toast.style.borderRadius = '4px';
            toast.style.fontSize = '14px';
            toast.style.zIndex = '9999';
            toast.style.transition = 'opacity 0.3s ease-in-out';
            document.body.appendChild(toast);
        }

        switch(type) {
            case 'success':
                toast.style.backgroundColor = '#4CAF50';
                toast.style.color = 'white';
                break;
            case 'warning':
                toast.style.backgroundColor = '#FF9800';
                toast.style.color = 'white';
                break;
            case 'error':
                toast.style.backgroundColor = '#F44336';
                toast.style.color = 'white';
                break;
            default:
                toast.style.backgroundColor = '#2196F3';
                toast.style.color = 'white';
                break;
        }

        toast.textContent = message;
        toast.style.opacity = '1';

        clearTimeout(toast.hideTimeout);
        toast.hideTimeout = setTimeout(() => {
            toast.style.opacity = '0';
        }, duration);
    }
})();