给Typecho造了个Picsur图床脚本

· 记录

图床&脚本简介

目前博主用的图床是自建的Picsur[1],这是一个开源项目。没找到合适的Typecho插件,实现在文章编辑页面粘贴、上传、拖拽等便捷上传图床和插入链接等功能。受NodeSeek官方图床通过外挂脚本实现和编辑器整合的启发,在原脚本基础上,修改为适配Typecho和Picsur的脚本。主要实现以下特性:

PS. Chrome升级到最新版本后已不支持Manifest V2,导致暴力猴[2]已经无法正常在Chrome上使用,推荐使用开源的脚本猫[3]

脚本内容

// ==UserScript==
// @name         Typecho Picsur 图片上传助手2
// @namespace    https://github.com/CaramelFur/Picsur
// @version      1.3.0
// @description  在Typecho编辑器中粘贴、拖拽或点击工具栏图片按钮,自动上传到Picsur图床,并解决原生功能冲突。支持自定义图床域名。
// @author       Cola (Modified by Gemini for Typecho)
// @match        *://*/admin/write-post.php*
// @icon         https://pic.bins.fyi/i/823e3430-2aa3-4fe6-8793-06376af9a334.webp
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @connect      * // 【重要】为了通用性,这里使用通配符。但请注意,某些浏览器或Tampermonkey版本可能需要手动添加具体域名到白名单。
// @license      MIT
// ==/UserScript==

(() => {
  'use strict';

  // ===== 全局配置 (Global Configuration) =====
  const APP = {
    api: {
      key: GM_getValue('picsur_apikey_v3', ''),
      setKey: key => {
        GM_setValue('picsur_apikey_v3', key);
        APP.api.key = key;
        UI.updateState();
      },
      clearKey: () => {
        GM_deleteValue('picsur_apikey_v3');
        APP.api.key = '';
        UI.updateState();
      },
      // 默认的API上传路径,不包含域名
      uploadPath: '/api/image/upload',
    },
    site: {
        // 默认的图片访问路径,不包含域名
        imagePath: '/i/',
        // 新增:图床域名,从GM存储中获取
        domain: GM_getValue('picsur_domain_v3', ''),
        setDomain: domain => {
            // 确保域名不以斜杠结尾
            APP.site.domain = domain.replace(/\/+$/, '');
            GM_setValue('picsur_domain_v3', APP.site.domain);
            UI.updateState();
        },
        clearDomain: () => {
            GM_deleteValue('picsur_domain_v3');
            APP.site.domain = '';
            UI.updateState();
        }
    },
    retry: { max: 2, delay: 1000 },
    statusTimeout: 2000,
  };

  // ===== DOM选择器 (DOM Selectors) =====
  const SELECTORS = {
    editor: '#text',
    toolbar: '.typecho-post-option',
    imgBtn: '#wmd-image-button',
    container: '#picsur-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: 'Picsur已就绪',
    UPLOADING: '正在上传...',
    UPLOAD_SUCCESS: '上传成功!',
    API_KEY_REQUIRED: '需要设置API Key',
    DOMAIN_REQUIRED: '需要设置图床域名',
    API_KEY_INVALID: 'API Key无效或错误',
    API_KEY_SET: 'API Key已设置!',
    DOMAIN_SET: '图床域名已设置!',
    RETRY: (current, max) => `重试上传 (${current}/${max})`,
    DOMAIN_CONNECT_WARNING: '注意:如果更改了图床域名,您可能需要手动编辑脚本的 @connect 部分,或在Tampermonkey设置中添加该域名到白名单。'
  };

  // ===== DOM缓存 (DOM Cache) =====
  const DOM = {
    editor: null,
    statusElements: new Set(),
    loginButtons: new Set(),
    logoutButtons: new Set(),
  };

  // ===== 全局样式 (Global Styles) =====
  GM_addStyle(`
    #picsur-toolbar-container { margin-top: 10px; padding: 10px; border-top: 1px solid #E9E9E9; display: flex; align-items: center; flex-wrap: wrap; }
    #picsur-status { font-size: 14px; line-height: 1.5; transition: all 0.3s ease; margin-right: 15px; }
    #picsur-status.success { color: ${STATUS.SUCCESS.color}; }
    #picsur-status.error { color: ${STATUS.ERROR.color}; }
    #picsur-status.warning { color: ${STATUS.WARNING.color}; }
    #picsur-status.info { color: ${STATUS.INFO.color}; }
    .picsur-btn { cursor: pointer; margin-left: 15px; font-size: 13px; background: #F7F7F7; padding: 5px 10px; border-radius: 4px; border: 1px solid #E9E9E9; user-select: none; white-space: nowrap; margin-bottom: 5px; }
    .picsur-btn:hover { border-color: #C9C9C9; }
    .picsur-login-btn { color: ${STATUS.WARNING.color}; }
    .picsur-logout-btn { color: ${STATUS.INFO.color}; }
  `);

  // ===== 工具函数 (Utility Functions) =====
  const Utils = {
    waitForElement: s => new Promise(r => { const e = document.querySelector(s); if (e) r(e); new MutationObserver((_, o) => { const f = document.querySelector(s); if (f) { o.disconnect(); r(f); } }).observe(document.body, { childList: true, subtree: true }); }),
    isEditingInEditor: () => document.activeElement === DOM.editor,
    delay: ms => new Promise(r => setTimeout(r, ms)),
    createFileInput: cb => {
      const i = Object.assign(document.createElement('input'), {
        type: 'file',
        multiple: true,
        accept: 'image/*'
      });
      i.onchange = e => cb([...e.target.files]);
      i.click();
    }
  };

  // ===== API通信 (API Communication) =====
  const API = {
    request: ({ url, method = 'GET', data = null, headers = {}, withAuth = false }) => {
      return new Promise((resolve, reject) => {
        const finalHeaders = { 'Accept': 'application/json', ...headers };
        if (withAuth && APP.api.key) {
          finalHeaders['Authorization'] = `Api-Key ${APP.api.key}`;
        }
        GM_xmlhttpRequest({
          method, url, headers: finalHeaders, data, responseType: 'json',
          onload: response => (response.status >= 200 && response.status < 300 && response.response) ? resolve(response.response) : reject(response),
          onerror: reject
        });
      });
    },
    uploadImage: async (file, retries = 0) => {
      // 动态构建上传URL
      const uploadUrl = `${APP.site.domain}${APP.api.uploadPath}`;
      try {
        const formData = new FormData();
        formData.append('image', file);
        const result = await API.request({ url: uploadUrl, method: 'POST', data: formData, withAuth: true });
        if (result && result.data && result.data.id) {
          // 动态构建图片URL
          const imageUrl = `${APP.site.domain}${APP.site.imagePath}${result.data.id}.webp`;
          return { url: imageUrl, markdown: `![${file.name}](${imageUrl})` };
        } else {
          throw new Error(result?.data?.message || result.error || '未知上传错误');
        }
      } catch (error) {
        if (error.status === 401 || error.status === 403) {
          APP.api.clearKey();
          throw new Error(MESSAGE.API_KEY_INVALID);
        }
        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.response?.data?.message || error.statusText || '上传失败'));
      }
    }
  };

  // ===== 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 && APP.site.domain);
      DOM.loginButtons.forEach(btn => btn.style.display = isLoggedIn ? 'none' : 'inline-block');
      DOM.logoutButtons.forEach(btn => btn.style.display = isLoggedIn ? 'inline-block' : 'none');
      DOM.statusElements.forEach(el => {
        if (isLoggedIn) {
          el.className = STATUS.SUCCESS.class;
          el.textContent = MESSAGE.READY;
        } else if (!APP.site.domain) {
          el.className = STATUS.WARNING.class;
          el.textContent = MESSAGE.DOMAIN_REQUIRED;
        } else {
          el.className = STATUS.WARNING.class;
          el.textContent = MESSAGE.API_KEY_REQUIRED;
        }
      });
    },
    promptForConfig: async () => {
        let domain = APP.site.domain;
        let key = APP.api.key;

        if (!domain) {
            domain = prompt("请输入您的Picsur图床域名 (例如: https://pic.example.com):");
            if (domain) {
                APP.site.setDomain(domain);
                setStatus(STATUS.SUCCESS.class, MESSAGE.DOMAIN_SET, APP.statusTimeout);
                alert(MESSAGE.DOMAIN_CONNECT_WARNING); // 提醒用户关于 @connect 的问题
            } else {
                setStatus(STATUS.WARNING.class, MESSAGE.DOMAIN_REQUIRED);
                return false;
            }
        }

        if (!key) {
            key = prompt("请从您的Picsur账户设置中复制并粘贴您的API Key:");
            if (key) {
                APP.api.setKey(key);
                setStatus(STATUS.SUCCESS.class, MESSAGE.API_KEY_SET, APP.statusTimeout);
            } else {
                setStatus(STATUS.WARNING.class, MESSAGE.API_KEY_REQUIRED);
                return false;
            }
        }
        return Boolean(APP.api.key && APP.site.domain);
    },
    setupToolbar: toolbar => {
      if (!toolbar || toolbar.querySelector(SELECTORS.container)) return;
      const container = document.createElement('div');
      container.id = 'picsur-toolbar-container';
      toolbar.appendChild(container);

      const statusEl = document.createElement('div');
      statusEl.id = 'picsur-status';
      container.appendChild(statusEl);
      DOM.statusElements.add(statusEl);

      const loginBtn = document.createElement('div');
      loginBtn.className = 'picsur-btn picsur-login-btn';
      loginBtn.textContent = '设置图床配置';
      loginBtn.addEventListener('click', UI.promptForConfig);
      container.appendChild(loginBtn);
      DOM.loginButtons.add(loginBtn);

      const logoutBtn = document.createElement('div');
      logoutBtn.className = 'picsur-btn picsur-logout-btn';
      logoutBtn.textContent = '重置配置';
      logoutBtn.addEventListener('click', async () => {
          APP.api.clearKey();
          APP.site.clearDomain();
          await UI.promptForConfig();
      });
      container.appendChild(logoutBtn);
      DOM.logoutButtons.add(logoutBtn);

      UI.updateState();
    }
  };

  // ===== 图片处理 (Image Handling) =====
  const ImageHandler = {
    handlePaste: async e => {
      if (!Utils.isEditingInEditor()) return;
      const dt = e.clipboardData || e.originalEvent?.clipboardData;
      if (!dt) return;
      const files = Array.from(dt.files).filter(f => f.type.startsWith('image/'));
      if (files.length > 0) {
        e.preventDefault();
        e.stopImmediatePropagation();
        if (!(await Auth.ensureAuthenticated())) return;
        ImageHandler.handleFiles(files);
      }
    },
    handleFiles: async files => {
      if (!(await Auth.ensureAuthenticated())) 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) {
        const errorMessage = `上传失败: ${error.message}`;
        console.error('[Picsur]', error);
        await setStatus(STATUS.ERROR.class, errorMessage, APP.statusTimeout * 2);
        if (error.message === MESSAGE.API_KEY_INVALID || error.message === MESSAGE.DOMAIN_REQUIRED) {
            await Auth.ensureAuthenticated(true); // 强制重新认证/配置
        }
      }
    },
    insertMarkdown: markdown => {
      const editor = DOM.editor;
      if (editor && typeof editor.selectionStart === 'number') {
        const start = editor.selectionStart;
        const end = editor.selectionEnd;
        const text = editor.value;
        const textToInsert = `\n${markdown}\n`;
        editor.value = text.substring(0, start) + textToInsert + text.substring(end);
        editor.selectionStart = editor.selectionEnd = start + textToInsert.length;
        editor.focus();
      }
    }
  };

  // ===== 认证管理 (Authentication Management) =====
  const Auth = {
    ensureAuthenticated: async (force = false) => {
      if (APP.api.key && APP.site.domain && !force) return true;
      if (force) {
          APP.api.clearKey();
          APP.site.clearDomain();
      }
      return await UI.promptForConfig();
    }
  };

  // ===== 初始化 (Initialization) =====
  const init = async () => {
    const editor = await Utils.waitForElement(SELECTORS.editor);
    const toolbar = await Utils.waitForElement(SELECTORS.toolbar);
    const originalImgBtn = await Utils.waitForElement(SELECTORS.imgBtn);

    DOM.editor = editor;
    UI.setupToolbar(toolbar);

    // --- 粘贴和拖拽事件绑定 ---
    editor.addEventListener('paste', ImageHandler.handlePaste, true);
    editor.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
    editor.addEventListener('drop', async e => {
      e.preventDefault();
      const files = Array.from(e.dataTransfer.files);
      if (files.length > 0) await ImageHandler.handleFiles(files);
    });

    // --- 重写工具栏图片按钮行为 ---
    if (originalImgBtn) {
      const newImgBtn = originalImgBtn.cloneNode(true);
      originalImgBtn.parentNode.replaceChild(newImgBtn, originalImgBtn);
      newImgBtn.addEventListener('click', async (e) => {
        e.preventDefault();
        e.stopPropagation();
        if (await Auth.ensureAuthenticated()) {
          Utils.createFileInput(ImageHandler.handleFiles);
        }
      });
    }

    UI.updateState();
    console.log('Typecho Picsur 图片上传助手已加载。');
  };

  window.addEventListener('load', init);

})();

使用和配置

Notes: [1]: https://github.com/CaramelFur/Picsur [2]: https://violentmonkey.github.io/ [3]: https://github.com/scriptscat/scriptcat

本文作者: 𝓬𝓸𝓵𝓪 🚀
本文链接: https://bb.bins.fyi/archives/120/
最后修改:
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!