图床&脚本简介
目前博主用的图床是自建的Picsur[1],这是一个开源项目。没找到合适的Typecho插件,实现在文章编辑页面粘贴、上传、拖拽等便捷上传图床和插入链接等功能。受NodeSeek官方图床通过外挂脚本实现和编辑器整合的启发,在原脚本基础上,修改为适配Typecho和Picsur的脚本。主要实现以下特性:
- 多方式上传:支持剪贴板粘贴、文件拖拽和点击编辑器图片按钮上传。
- 自动处理:图片上传后自动生成 Markdown 链接并插入到文章中。
- 通用性:允许用户自定义 Picsur 图床的域名和 API Key。
- 冲突解决:重写 Typecho 原生图片按钮行为,避免功能冲突。
- 状态反馈:提供实时上传状态和配置提示。
- 错误重试:内置上传失败重试机制。
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: `` };
} 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);
})();
使用和配置
-
在你的脚本管理器中新建脚本,复制脚本内容并保存
-
登陆Typecho后台,点击“撰写”--“撰写文章”
-
脚本自动加载,一般在编辑器下方会出现
设置图床配置的按钮 -
点击按钮首先会弹出一个提示框,要求输入 Picsur 图床域名
- 重要提示:请确保输入完整的协议头(
http://或https://),并且不要在末尾添加斜杠。 - 输入 API Key:成功设置域名后,会再次弹出一个提示框,要求输入 Picsur API Key。
- 确认:输入完成后,点击“确定”。脚本会保存您的配置,并显示“Picsur已就绪”状态。
- 重要提示:请确保输入完整的协议头(
-
配置完成后,即可使用。
Notes: [1]: https://github.com/CaramelFur/Picsur [2]: https://violentmonkey.github.io/ [3]: https://github.com/scriptscat/scriptcat