V2EX Image Uploader

在 V2EX 评论区快速上传图片并插入链接

  1. // ==UserScript==
  2. // @name V2EX Image Uploader
  3. // @namespace http://tampermonkey.net/1436051
  4. // @version 1.0
  5. // @description 在 V2EX 评论区快速上传图片并插入链接
  6. // @author Dogxi
  7. // @match https://www.v2ex.com/t/*
  8. // @match https://v2ex.com/t/*
  9. // @icon https://www.google.com/s2/favicons?sz=64&domain=v2ex.com
  10. // @grant GM_xmlhttpRequest
  11. // @grant GM_setValue
  12. // @grant GM_getValue
  13. // @connect api.imgur.com
  14. // @license MIT
  15. // ==/UserScript==
  16.  
  17. (function() {
  18. 'use strict';
  19.  
  20. const IMGUR_CLIENT_ID_KEY = 'imgurClientId';
  21. let CLIENT_ID = GM_getValue(IMGUR_CLIENT_ID_KEY, null);
  22.  
  23. const STYLE = `
  24. .imgur-upload-btn {
  25. background: none;
  26. border: none;
  27. color: #778087;
  28. cursor: pointer;
  29. font-size: 13px;
  30. padding: 0;
  31. margin-left: 15px;
  32. text-decoration: none;
  33. transition: color 0.2s ease;
  34. }
  35. .imgur-upload-btn:hover {
  36. color: #4d5256;
  37. text-decoration: underline;
  38. }
  39. .hidden {
  40. display: none !important;
  41. }
  42. .imgur-upload-modal {
  43. position: fixed;
  44. top: 0;
  45. left: 0;
  46. width: 100%;
  47. height: 100%;
  48. background-color: rgba(0, 0, 0, 0.5);
  49. display: flex;
  50. justify-content: center;
  51. align-items: center;
  52. z-index: 9999;
  53. }
  54. .imgur-upload-modal-content {
  55. background-color: #fff;
  56. padding: 20px;
  57. border-radius: 3px;
  58. box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  59. max-width: 450px;
  60. width: 90%;
  61. position: relative;
  62. font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  63. }
  64. .imgur-upload-modal-header {
  65. display: flex;
  66. justify-content: space-between;
  67. align-items: center;
  68. margin-bottom: 15px;
  69. padding-bottom: 10px;
  70. border-bottom: 1px solid #e2e2e2;
  71. }
  72. .imgur-upload-modal-title {
  73. font-size: 15px;
  74. font-weight: normal;
  75. color: #000;
  76. }
  77. .imgur-upload-modal-close {
  78. cursor: pointer;
  79. font-size: 18px;
  80. color: #ccc;
  81. width: 20px;
  82. height: 20px;
  83. display: flex;
  84. align-items: center;
  85. justify-content: center;
  86. transition: color 0.2s ease;
  87. }
  88. .imgur-upload-modal-close:hover {
  89. color: #999;
  90. }
  91. .imgur-upload-dropzone {
  92. border: 1px dashed #ccc;
  93. padding: 25px;
  94. text-align: center;
  95. margin-bottom: 15px;
  96. cursor: pointer;
  97. border-radius: 3px;
  98. transition: border-color 0.2s ease;
  99. font-size: 13px;
  100. color: #666;
  101. }
  102. .imgur-upload-dropzone:hover {
  103. border-color: #999;
  104. }
  105. .imgur-upload-dropzone.dragover {
  106. border-color: #778087;
  107. background-color: #f9f9f9;
  108. }
  109. .imgur-upload-preview {
  110. margin-top: 10px;
  111. max-width: 100%;
  112. max-height: 150px;
  113. border-radius: 2px;
  114. }
  115. .imgur-upload-actions {
  116. display: flex;
  117. justify-content: space-between;
  118. align-items: center;
  119. margin-top: 15px;
  120. padding-top: 10px;
  121. border-top: 1px solid #e2e2e2;
  122. }
  123. .imgur-upload-config-btn {
  124. background: none;
  125. border: none;
  126. color: #778087;
  127. cursor: pointer;
  128. font-size: 12px;
  129. padding: 0;
  130. }
  131. .imgur-upload-config-btn:hover {
  132. color: #4d5256;
  133. text-decoration: underline;
  134. }
  135. .imgur-upload-submit-btn {
  136. background-color: #f5f5f5;
  137. border: 1px solid #ccc;
  138. border-radius: 3px;
  139. color: #333;
  140. cursor: pointer;
  141. font-size: 12px;
  142. padding: 6px 12px;
  143. transition: all 0.2s ease;
  144. }
  145. .imgur-upload-submit-btn:hover {
  146. background-color: #e8e8e8;
  147. }
  148. .imgur-upload-submit-btn:disabled {
  149. background-color: #f9f9f9;
  150. color: #ccc;
  151. cursor: not-allowed;
  152. }
  153. .imgur-upload-config-panel {
  154. margin-top: 10px;
  155. padding: 10px;
  156. background-color: #f9f9f9;
  157. border-radius: 3px;
  158. border: 1px solid #e2e2e2;
  159. }
  160. .imgur-upload-config-row {
  161. display: flex;
  162. align-items: center;
  163. margin-bottom: 8px;
  164. }
  165. .imgur-upload-config-row:last-child {
  166. margin-bottom: 0;
  167. }
  168. .imgur-upload-config-label {
  169. font-size: 12px;
  170. color: #666;
  171. width: 70px;
  172. flex-shrink: 0;
  173. }
  174. .imgur-upload-config-input {
  175. flex: 1;
  176. padding: 3px 6px;
  177. border: 1px solid #ccc;
  178. border-radius: 2px;
  179. font-size: 12px;
  180. }
  181. .imgur-upload-config-save {
  182. background-color: #f5f5f5;
  183. border: 1px solid #ccc;
  184. border-radius: 2px;
  185. color: #333;
  186. cursor: pointer;
  187. font-size: 11px;
  188. margin-left: 6px;
  189. padding: 3px 8px;
  190. }
  191. .imgur-upload-config-save:hover {
  192. background-color: #e8e8e8;
  193. }
  194. .imgur-upload-modal-status {
  195. color: #666;
  196. font-size: 12px;
  197. text-align: center;
  198. }
  199. .imgur-upload-modal-status.success {
  200. color: #5cb85c;
  201. }
  202. .imgur-upload-modal-status.error {
  203. color: #d9534f;
  204. }
  205. `;
  206.  
  207. // 添加样式到页面
  208. function addStyle() {
  209. const styleElement = document.createElement('style');
  210. styleElement.textContent = STYLE;
  211. document.head.appendChild(styleElement);
  212. }
  213.  
  214. // 创建上传弹窗
  215. function createUploadModal(textareaElement) {
  216. const modal = document.createElement('div');
  217. modal.className = 'imgur-upload-modal';
  218. const content = document.createElement('div');
  219. content.className = 'imgur-upload-modal-content';
  220. content.innerHTML = `
  221. <div class="imgur-upload-modal-header">
  222. <div class="imgur-upload-modal-title">上传图片</div>
  223. <div class="imgur-upload-modal-close">×</div>
  224. </div>
  225. <div class="imgur-upload-dropzone">
  226. <div>点击选择图片或拖拽图片到此处</div>
  227. <div style="font-size: 11px; color: #999; margin-top: 5px;">支持 JPG, PNG, GIF 格式</div>
  228. </div>
  229. <div class="imgur-upload-actions">
  230. <button class="imgur-upload-config-btn">⚙️ 配置</button>
  231. <button class="imgur-upload-submit-btn" disabled>确认上传</button>
  232. </div>
  233. <div class="imgur-upload-config-panel hidden">
  234. <div class="imgur-upload-config-row">
  235. <div class="imgur-upload-config-label">Imgur ID:</div>
  236. <input type="text" class="imgur-upload-config-input" placeholder="请输入 Imgur Client ID" value="${CLIENT_ID || ''}">
  237. <button class="imgur-upload-config-save">保存</button>
  238. </div>
  239. <div style="font-size: 11px; color: #666; margin-top: 8px;">
  240. <a href="https://api.imgur.com/oauth2/addclient" target="_blank">https://api.imgur.com/oauth2/addclient</a> 注册(不可用)获取(无回调)
  241. </div>
  242. </div>
  243. `;
  244. modal.appendChild(content);
  245. document.body.appendChild(modal);
  246. setupModalEvents(modal, textareaElement);
  247. return modal;
  248. }
  249.  
  250. // 设置弹窗事件监听
  251. function setupModalEvents(modal, textareaElement) {
  252. const closeBtn = modal.querySelector('.imgur-upload-modal-close');
  253. const dropzone = modal.querySelector('.imgur-upload-dropzone');
  254. const configBtn = modal.querySelector('.imgur-upload-config-btn');
  255. const configPanel = modal.querySelector('.imgur-upload-config-panel');
  256. const configInput = modal.querySelector('.imgur-upload-config-input');
  257. const configSave = modal.querySelector('.imgur-upload-config-save');
  258. const submitBtn = modal.querySelector('.imgur-upload-submit-btn');
  259. let selectedFile = null;
  260. function closeModal() {
  261. document.body.removeChild(modal);
  262. }
  263. closeBtn.addEventListener('click', closeModal);
  264. modal.addEventListener('click', function(e) {
  265. if (e.target === modal) closeModal();
  266. });
  267. configBtn.addEventListener('click', function() {
  268. configPanel.classList.toggle('hidden');
  269. });
  270. configSave.addEventListener('click', function() {
  271. const newClientId = configInput.value.trim();
  272. if (newClientId) {
  273. GM_setValue(IMGUR_CLIENT_ID_KEY, newClientId);
  274. CLIENT_ID = newClientId;
  275. configPanel.classList.add('hidden');
  276. showStatusInModal(modal, '配置已保存', 'success');
  277. }
  278. });
  279. const fileInput = document.createElement('input');
  280. fileInput.type = 'file';
  281. fileInput.accept = 'image/*';
  282. fileInput.style.display = 'none';
  283. modal.appendChild(fileInput);
  284. dropzone.addEventListener('click', () => fileInput.click());
  285. fileInput.addEventListener('change', function(e) {
  286. handleFileSelect(e.target.files[0]);
  287. });
  288. dropzone.addEventListener('dragover', function(e) {
  289. e.preventDefault();
  290. dropzone.classList.add('dragover');
  291. });
  292. dropzone.addEventListener('dragleave', function(e) {
  293. e.preventDefault();
  294. dropzone.classList.remove('dragover');
  295. });
  296. dropzone.addEventListener('drop', function(e) {
  297. e.preventDefault();
  298. dropzone.classList.remove('dragover');
  299. const files = e.dataTransfer.files;
  300. if (files.length > 0) {
  301. handleFileSelect(files[0]);
  302. }
  303. });
  304. // 处理文件选择
  305. function handleFileSelect(file) {
  306. if (!file || !file.type.match(/image\/.*/)) {
  307. showStatusInModal(modal, '请选择图片文件', 'error');
  308. return;
  309. }
  310. selectedFile = file;
  311. const reader = new FileReader();
  312. reader.onload = function(e) {
  313. const preview = modal.querySelector('.imgur-upload-preview');
  314. if (preview) preview.remove();
  315. const img = document.createElement('img');
  316. img.src = e.target.result;
  317. img.className = 'imgur-upload-preview';
  318. dropzone.appendChild(img);
  319. submitBtn.disabled = false;
  320. dropzone.querySelector('div').textContent = '已选择: ' + file.name;
  321. };
  322. reader.readAsDataURL(file);
  323. }
  324. submitBtn.addEventListener('click', function() {
  325. if (!selectedFile) return;
  326. if (!CLIENT_ID) {
  327. showStatusInModal(modal, '请先配置 Imgur Client ID', 'error');
  328. configPanel.classList.remove('hidden');
  329. return;
  330. }
  331. submitBtn.disabled = true;
  332. submitBtn.textContent = '上传中...';
  333. uploadToImgur(selectedFile, textareaElement, modal);
  334. });
  335. }
  336.  
  337. // 在弹窗中显示状态信息
  338. function showStatusInModal(modal, message, type) {
  339. let statusEl = modal.querySelector('.imgur-upload-modal-status');
  340. if (!statusEl) {
  341. statusEl = document.createElement('div');
  342. statusEl.className = 'imgur-upload-modal-status';
  343. statusEl.style.cssText = 'margin-top: 10px; font-size: 12px; text-align: center;';
  344. modal.querySelector('.imgur-upload-modal-content').appendChild(statusEl);
  345. }
  346. statusEl.textContent = message;
  347. statusEl.className = 'imgur-upload-modal-status ' + (type || '');
  348. if (type === 'success') {
  349. setTimeout(() => statusEl.textContent = '', 3000);
  350. }
  351. }
  352.  
  353. // 上传图片到 Imgur
  354. function uploadToImgur(file, textareaElement, modal) {
  355. if (!file.type.match(/image\/.*/)) {
  356. showStatusInModal(modal, '请选择图片文件', 'error');
  357. const submitBtn = modal.querySelector('.imgur-upload-submit-btn');
  358. submitBtn.disabled = false;
  359. submitBtn.textContent = '确认上传';
  360. return;
  361. }
  362. const formData = new FormData();
  363. formData.append('image', file);
  364. GM_xmlhttpRequest({
  365. method: "POST",
  366. url: "https://api.imgur.com/3/image",
  367. headers: {
  368. "Authorization": "Client-ID " + CLIENT_ID
  369. },
  370. data: formData,
  371. responseType: "json",
  372. onload: function(response) {
  373. const submitBtn = modal.querySelector('.imgur-upload-submit-btn');
  374. try {
  375. let responseData;
  376. if (typeof response.response === 'string') {
  377. responseData = JSON.parse(response.response);
  378. } else {
  379. responseData = response.response;
  380. }
  381. if (response.status === 200 && responseData && responseData.success) {
  382. const imageUrl = responseData.data.link;
  383. insertLinkIntoTextarea(textareaElement, imageUrl, file.name);
  384. showStatusInModal(modal, '上传成功!', 'success');
  385. setTimeout(() => {
  386. document.body.removeChild(modal);
  387. }, 1500);
  388. } else {
  389. let errorMessage = '';
  390. if (response.status === 400) {
  391. if (responseData && responseData.data && responseData.data.error) {
  392. if (responseData.data.error === 'These actions are forbidden.') {
  393. errorMessage = 'Client ID 无效或已被禁用,请检查配置';
  394. } else {
  395. errorMessage = responseData.data.error;
  396. }
  397. } else {
  398. errorMessage = 'Client ID 配置错误';
  399. }
  400. } else if (response.status === 403) {
  401. errorMessage = '访问被拒绝,请检查 Client ID 权限';
  402. } else if (response.status === 429) {
  403. errorMessage = '请求过于频繁,请稍后再试';
  404. } else {
  405. errorMessage = `上传失败 (${response.status})`;
  406. }
  407. console.error('Imgur 上传错误:', response);
  408. showStatusInModal(modal, errorMessage, 'error');
  409. if (response.status === 400 || response.status === 403) {
  410. const configPanel = modal.querySelector('.imgur-upload-config-panel');
  411. configPanel.classList.remove('hidden');
  412. }
  413. submitBtn.disabled = false;
  414. submitBtn.textContent = '确认上传';
  415. }
  416. } catch (e) {
  417. console.error('解析响应失败:', e, response);
  418. showStatusInModal(modal, '响应解析失败,请重试', 'error');
  419. submitBtn.disabled = false;
  420. submitBtn.textContent = '确认上传';
  421. }
  422. },
  423. onerror: function(error) {
  424. console.error('GM_xmlhttpRequest 错误:', error);
  425. showStatusInModal(modal, '网络请求失败,请检查连接', 'error');
  426. const submitBtn = modal.querySelector('.imgur-upload-submit-btn');
  427. submitBtn.disabled = false;
  428. submitBtn.textContent = '确认上传';
  429. },
  430. ontimeout: function() {
  431. console.error('Imgur 上传超时');
  432. showStatusInModal(modal, '上传超时,请重试', 'error');
  433. const submitBtn = modal.querySelector('.imgur-upload-submit-btn');
  434. submitBtn.disabled = false;
  435. submitBtn.textContent = '确认上传';
  436. }
  437. });
  438. }
  439.  
  440. // 将图片链接插入到文本框
  441. function insertLinkIntoTextarea(textareaElement, imageUrl, fileName) {
  442. const altText = fileName ? fileName.split('.')[0] : 'image';
  443. const textToInsert = imageUrl;
  444. const currentValue = textareaElement.value;
  445. const selectionStart = textareaElement.selectionStart;
  446. const selectionEnd = textareaElement.selectionEnd;
  447. const newText = currentValue.substring(0, selectionStart) + textToInsert + currentValue.substring(selectionEnd);
  448. textareaElement.value = newText;
  449. const newCursorPosition = selectionStart + textToInsert.length;
  450. textareaElement.selectionStart = newCursorPosition;
  451. textareaElement.selectionEnd = newCursorPosition;
  452. textareaElement.focus();
  453. textareaElement.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
  454. }
  455.  
  456. // 在页面头部添加上传按钮
  457. function addUploadButtonToHeader() {
  458. const replyBox = document.getElementById('reply-box');
  459. if (!replyBox) return;
  460. const headerCell = replyBox.querySelector('.cell.flex-one-row');
  461. if (!headerCell) return;
  462. if (headerCell.querySelector('.imgur-upload-btn')) return;
  463. const leftDiv = headerCell.querySelector('div:first-child');
  464. if (leftDiv) {
  465. const uploadBtn = document.createElement('a');
  466. uploadBtn.className = 'imgur-upload-btn';
  467. uploadBtn.textContent = '上传';
  468. uploadBtn.href = 'javascript:void(0);';
  469. uploadBtn.title = '上传图片';
  470. uploadBtn.style.marginLeft = '10px';
  471. leftDiv.appendChild(uploadBtn);
  472. uploadBtn.addEventListener('click', function(e) {
  473. e.preventDefault();
  474. const textarea = document.getElementById('reply_content');
  475. if (textarea) {
  476. createUploadModal(textarea);
  477. }
  478. });
  479. }
  480. }
  481.  
  482. // 查找并添加上传按钮
  483. function findTextareasAndAddButtons() {
  484. addUploadButtonToHeader();
  485. setupMutationObserver();
  486. }
  487.  
  488. // 监听DOM变化
  489. function setupMutationObserver() {
  490. const observer = new MutationObserver(function(mutations) {
  491. let shouldCheck = false;
  492. mutations.forEach(function(mutation) {
  493. mutation.addedNodes.forEach(function(node) {
  494. if (node.nodeType === Node.ELEMENT_NODE &&
  495. (node.id === 'reply-box' || node.querySelector('#reply-box'))) {
  496. shouldCheck = true;
  497. }
  498. });
  499. });
  500. if (shouldCheck) {
  501. setTimeout(addUploadButtonToHeader, 100);
  502. }
  503. });
  504. observer.observe(document.body, {
  505. childList: true,
  506. subtree: true
  507. });
  508. }
  509.  
  510. // 初始化脚本
  511. function init() {
  512. addStyle();
  513. setTimeout(findTextareasAndAddButtons, 100);
  514. }
  515.  
  516. if (document.readyState === "loading") {
  517. document.addEventListener("DOMContentLoaded", init);
  518. } else {
  519. init();
  520. }
  521. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址