Greasy Fork is available in English.

futaba-add-uploader

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

// ==UserScript==
// @name         futaba-add-uploader
// @namespace    http://2chan.net/
// @version      0.1.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 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>あぷ小に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">※あぷ小は3MBまで</span>
      </td>
    </tr>`;
    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) => {
      const formData = new FormData();
      const MAX_FILE_SIZE = 3000000;
      const sha1 = await calculateSHA1(file);
      formData.append('MAX_FILE_SIZE', String(MAX_FILE_SIZE));
      formData.append('mode', 'reg');
      formData.append('up', file);
      formData.append('com', sha1);
      try {
        await fetch(`${location.protocol}//dec.2chan.net/up2/up.php`, {
          method: 'POST',
          body: formData,
        });
      } catch (e) {
      } finally {
        hideErrorText();
        return sha1;
      }
    };
    const getUploaderHTML = () => {
      return new Promise((resolve) => {
        GM_xmlhttpRequest({
          method: 'GET',
          url: `${location.protocol}//dec.2chan.net/up2/up.htm`,
          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 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;
      };
      const MAX_FILE_SIZE = 3000000;
      hideErrorText();
      setInputState.disabled(up2inputElm, up2submitElm);
      if (!up2inputElm.value || up2inputElm.files === null) {
        setError('ファイルが選択されていません');
        return;
      }
      const file = up2inputElm.files[0];
      if (file.size > MAX_FILE_SIZE) {
        setError('※あぷ小は3MBまで');
        return;
      }
      if (!isAllowExtension(up2inputElm)) {
        setError('アップロードが許可されていない拡張子です');
        return;
      }
      // ファイルのアップロードとSHA-1の取得
      const sha1 = await uploadFile(file);
      if (!sha1) {
        setError('アップロードファイルのSHA-1取得に失敗しました');
        return;
      }
      // あぷ小のHTML取得
      const uploaderHTML = await getUploaderHTML();
      if (!uploaderHTML) {
        setError('あぷ小のHTML取得に失敗しました');
        return;
      }
      // あぷ小のDOM取得
      const uploaderDocument = htmlParser(uploaderHTML);
      if (!uploaderDocument) {
        setError('あぷ小の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('あぷ小にアップロードしたファイルが見つかりませんでした');
        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);
        });
      }
    }
  };
  const initialize = () => {
    const styleElm = document.querySelector('#userjs-add-uploader');
    if (styleElm === null) {
      document.head.insertAdjacentHTML('beforeend', addStyle);
      addUploader();
    }
  };
  initialize();
  window.addEventListener('load', initialize);
})();