futaba-add-uploader

ふたばちゃんねるの投稿フォームに「あぷ小」または「あぷ」へのアップロード機能を追加します

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         futaba-add-uploader
// @namespace    http://2chan.net/
// @version      0.2.1
// @description  ふたばちゃんねるの投稿フォームに「あぷ小」または「あぷ」へのアップロード機能を追加します
// @author       ame-chan
// @match        http://*.2chan.net/b/res/*
// @match        https://*.2chan.net/b/res/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=2chan.net
// @grant        GM_xmlhttpRequest
// @license      MIT
// @run-at       document-idle
// @connect      2chan.net
// @connect      *.2chan.net
// @connect      img.2chan.net
// @connect      dec.2chan.net
// ==/UserScript==
(() => {
  'use strict';
  const addStyle = `<style id="userjs-add-uploader">
  .ftbl {
    width: 510px;
  }
  [target="futaba_viewer_postcontents"] .ftbl {
    margin: 0 0 32px !important;
  }
  .ftdc {
    width: 100px;
  }
  #up2input + #fileselector-button-clear {
    display: none !important;
  }
  #up2error {
    display: none;
    color: #ff0000;
  }
  #up2error.is-visible {
    display: block;
  }
  .userjs-uploadcell:has(#file_control) {
    display: flex;
    align-items: flex-start;
    flex-wrap: wrap;
  }
  .userjs-loading {
    display: flex;
    gap: 4px;
    align-items: center;
    justify-content: center;
  }
  </style>`;
  const targetUploader = {
    'あぷ小': {
      name: 'あぷ小',
      max_file_size: 3000000,
      max_file_size_text: '※あぷ小は3MBまで',
      post_url: '//dec.2chan.net/up2/up.php',
      get_url: '//dec.2chan.net/up2/up.htm',
    },
    'あぷ': {
      name: 'あぷ',
      max_file_size: 10000000,
      max_file_size_text: '※あぷは10MBまで',
      post_url: '//dec.2chan.net/up/up.php',
      get_url: '//dec.2chan.net/up/up.htm',
    },
  };
  const showErrorText = () => document.querySelector('#up2error')?.classList.add('is-visible');
  const hideErrorText = () => document.querySelector('#up2error')?.classList.remove('is-visible');
  const addUploader = () => {
    const inputAreaElm = document.querySelector('.ftbl tbody');
    const html = `<tr>
      <td class="ftdc">
        <b><span data-uploader-name>あぷ小</span>にUP</b>
      </td>
      <td class="userjs-uploadcell">
        <input id="up2input" name="userjs-uploader" type="file" size="40">
        <button id="up2submit" type="button">アップロード</button>
        <span id="up2error" data-uploader-text>※あぷ小は3MBまで</span>
      </td>
    </tr>`;
    const replaceHtml = (uploader) => {
      const nameElm = document.querySelector('[data-uploader-name]');
      const errorTextElm = document.querySelector('[data-uploader-text]');
      if (nameElm) {
        nameElm.textContent = uploader.name;
      }
      if (errorTextElm) {
        errorTextElm.textContent = uploader.max_file_size_text;
      }
    };
    const arrayBufferToHex = (arrayBuffer) =>
      Array.from(new Uint8Array(arrayBuffer))
        .map((b) => b.toString(16).padStart(2, '0'))
        .join('');
    const calculateSHA1 = async (file) => {
      const buffer = await file.arrayBuffer();
      const message = new TextEncoder().encode(arrayBufferToHex(buffer));
      const hashBuffer = await crypto.subtle.digest('SHA-1', message);
      return arrayBufferToHex(hashBuffer);
    };
    const setInputState = {
      disabled(up2inputElm, up2submit) {
        up2inputElm.disabled = true;
        up2submit.innerHTML = `<div class="userjs-loading">
          <svg width="18px" height="18px" display="block" shape-rendering="auto" style="background:none;margin:auto" preserveAspectRatio="xMidYMid" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
            <circle cx="50" cy="50" r="35" fill="none" stroke="#aaa" stroke-dasharray="164.93361431346415 56.97787143782138" stroke-width="10">
              <animateTransform attributeName="transform" dur="1s" keyTimes="0;1" repeatCount="indefinite" type="rotate" values="0 50 50;360 50 50"/>
            </circle>
          </svg>
          <span>アップロード中...</span>
        </div>`;
        up2submit.disabled = true;
      },
      enabled(up2inputElm, up2submit) {
        up2inputElm.disabled = false;
        up2submit.innerHTML = 'アップロード';
        up2submit.disabled = false;
      },
    };
    const setErrorText = (text) => {
      const errorElm = document.querySelector('#up2error');
      if (errorElm) {
        errorElm.textContent = text;
      }
    };
    const setError = (text) => {
      const up2inputElm = document.querySelector('#up2input');
      const up2submitElm = document.querySelector('#up2submit');
      if (up2inputElm instanceof HTMLInputElement && up2submitElm instanceof HTMLButtonElement) {
        setErrorText(text);
        showErrorText();
        setInputState.enabled(up2inputElm, up2submitElm);
      }
    };
    const uploadFile = async (file, uploader) => {
      const formData = new FormData();
      const sha1 = await calculateSHA1(file);
      formData.append('MAX_FILE_SIZE', String(uploader.max_file_size));
      formData.append('mode', 'reg');
      formData.append('up', file);
      formData.append('com', sha1);
      try {
        await fetch(`${location.protocol}${uploader.post_url}`, {
          method: 'POST',
          body: formData,
        });
      } catch (e) {
      } finally {
        hideErrorText();
        return sha1;
      }
    };
    const getUploaderHTML = (uploader) => {
      return new Promise((resolve) => {
        GM_xmlhttpRequest({
          method: 'GET',
          url: `${location.protocol}${uploader.get_url}`,
          responseType: 'arraybuffer',
          headers: {
            'Cache-Control': 'no-cache',
          },
          onload: (response) => {
            if (response.status === 200) {
              resolve(response.response);
            } else {
              resolve(false);
            }
          },
          onerror: () => resolve(false),
        });
      });
    };
    const isAllowExtension = (up2inputElm) => {
      const allowExtension =
        /\.(3g2|3gp|7z|ai|aif|asf|avi|bmp|c|doc|eps|exe|f4v|flv|gca|gif|htm|html|jpeg|jpg|lzh|m4a|mgx|mht|mid|mkv|mmf|mov|mp3|mp4|mpeg|mpg|mpo|mqo|ogg|pdf|pls|png|ppt|psd|ram|rar|rm|rpy|sai|swf|tif|tiff|txt|wav|webm|webp|wma|wmv|xls|zip)$/;
      return allowExtension.test(up2inputElm.value);
    };
    const selectedUploader = (size) => {
      const up2MaxSize = targetUploader['あぷ小'].max_file_size;
      return size > up2MaxSize ? targetUploader['あぷ'] : targetUploader['あぷ小'];
    };
    const uploadHandler = async (up2inputElm, up2submitElm) => {
      const htmlParser = (uploaderHTML) => {
        const textDecoder = new TextDecoder('Shift_JIS');
        const html = textDecoder.decode(uploaderHTML);
        const parser = new DOMParser();
        const dom = parser.parseFromString(html, 'text/html');
        if (dom) {
          return dom;
        }
        return false;
      };
      hideErrorText();
      setInputState.disabled(up2inputElm, up2submitElm);
      if (!up2inputElm.value || up2inputElm.files === null) {
        setError('ファイルが選択されていません');
        return;
      }
      const file = up2inputElm.files[0];
      const uploader = selectedUploader(file.size);
      if (file.size > uploader.max_file_size) {
        setError(uploader.max_file_size_text);
        return;
      }
      if (!isAllowExtension(up2inputElm)) {
        setError('アップロードが許可されていない拡張子です');
        return;
      }
      // ファイルのアップロードとSHA-1の取得
      const sha1 = await uploadFile(file, uploader);
      if (!sha1) {
        setError('アップロードファイルのSHA-1取得に失敗しました');
        return;
      }
      // uploaderのHTML取得
      const uploaderHTML = await getUploaderHTML(uploader);
      if (!uploaderHTML) {
        setError(`${uploader.name}のHTML取得に失敗しました`);
        return;
      }
      // uploaderのDOM取得
      const uploaderDocument = htmlParser(uploaderHTML);
      if (!uploaderDocument) {
        setError(`${uploader.name}のDOM取得に失敗しました`);
        return;
      }
      const files = uploaderDocument.querySelector('.files tbody');
      let uploadFileName = '';
      for (const el of [...(files?.children || [])]) {
        const comment = (el.querySelector('.fco')?.textContent || '').replace(/[\s\n\t]+/g, '');
        if (comment === sha1) {
          uploadFileName = el.querySelector('.fnm a')?.textContent || '';
          break;
        }
      }
      if (!uploadFileName) {
        setError(`${uploader.name}にアップロードしたファイルが見つかりませんでした`);
        return;
      }
      const textareaElm = document.querySelector('#ftxa');
      if (textareaElm) {
        textareaElm.value = textareaElm.value.length ? `${textareaElm.value}\n${uploadFileName}` : uploadFileName;
        up2inputElm.value = '';
        hideErrorText();
        setInputState.enabled(up2inputElm, up2submitElm);
        // ふたクロでプレビュー表示していた場合削除
        const previewElm = document.querySelector('#upfile_preview_wrap');
        if (previewElm) {
          previewElm.innerHTML = '';
        }
      }
    };
    if (inputAreaElm) {
      inputAreaElm.insertAdjacentHTML('beforeend', html);
      const up2inputElm = document.querySelector('#up2input');
      const up2submitElm = document.querySelector('#up2submit');
      if (up2inputElm instanceof HTMLInputElement && up2submitElm instanceof HTMLButtonElement) {
        up2submitElm.addEventListener('click', () => {
          void uploadHandler(up2inputElm, up2submitElm);
        });
        up2inputElm.addEventListener('change', (event) => {
          const files = event.target.files;
          if (files && files.length) {
            const uploader = selectedUploader(files[0].size);
            replaceHtml(uploader);
          }
        });
      }
    }
  };
  const initialize = () => {
    const styleElm = document.querySelector('#userjs-add-uploader');
    if (styleElm === null) {
      document.head.insertAdjacentHTML('beforeend', addStyle);
      addUploader();
    }
  };
  initialize();
  window.addEventListener('load', initialize);
})();