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);
})();