Futaba Image Preview

ふたばちゃんねるのスレッド上で「あぷ」「あぷ小」の画像をプレビュー表示する

目前為 2023-07-28 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Futaba Image Preview
// @namespace    http://2chan.net/
// @version      0.1.1
// @description  ふたばちゃんねるのスレッド上で「あぷ」「あぷ小」の画像をプレビュー表示する
// @author       ame-chan
// @match        http://*.2chan.net/b/res/*
// @match        https://*.2chan.net/b/res/*
// @match        http://kako.futakuro.com/futa/*
// @match        https://kako.futakuro.com/futa/*
// @match        https://tsumanne.net/si/data/*
// @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      tsumanne.net
// ==/UserScript==
(async () => {
  'use strict';
  let initExecCreateLink = false;
  let initTimer;
  const addedStyle = `<style id="userjs-preview-style">
  .zoom_button.not_copy_button {
    display: none;
  }
  .userjs-preview-link {
    padding-right: 24px;
    background-image: url('data:image/svg+xml;charset=utf8,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2038%2038%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20stroke%3D%22%23000%22%3E%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Cg%20transform%3D%22translate(1%201)%22%20stroke-width%3D%222%22%3E%3Ccircle%20stroke-opacity%3D%22.5%22%20cx%3D%2218%22%20cy%3D%2218%22%20r%3D%2218%22%2F%3E%3Cpath%20d%3D%22M36%2018c0-9.94-8.06-18-18-18%22%3E%20%3CanimateTransform%20attributeName%3D%22transform%22%20type%3D%22rotate%22%20from%3D%220%2018%2018%22%20to%3D%22360%2018%2018%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%2F%3E%3C%2Fpath%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E');
    background-repeat: no-repeat;
    background-position: right center;
  }
  .userjs-preview-imageWrap {
    max-width: calc(100vw - 200px);
    width: fit-content;
  }
  .userjs-preview-image {
    max-width: calc(100vw - 200px) !important;
    max-height: none !important;
    transition: all 0.2s ease-in-out;
    border-radius: 4px;
    cursor: pointer;
  }
  .userjs-preview-title {
    display: flex;
    flex-direction: row;
    margin: 8px 0 16px;
    gap: 16px;
    padding: 16px;
    line-height: 1.6 !important;
    color: #ff3860 !important;
    background-color: #fff;
    border-radius: 4px;
  }
  </style>`;
  if (!document.querySelector('#userjs-preview-style')) {
    document.head.insertAdjacentHTML('beforeend', addedStyle);
  }
  class FileReaderEx extends FileReader {
    constructor() {
      super();
    }
    #readAs(blob, ctx) {
      return new Promise((res, rej) => {
        super.addEventListener('load', ({ target }) => target?.result && res(target.result));
        super.addEventListener('error', ({ target }) => target?.error && rej(target.error));
        super[ctx](blob);
      });
    }
    readAsArrayBuffer(blob) {
      return this.#readAs(blob, 'readAsArrayBuffer');
    }
    readAsDataURL(blob) {
      return this.#readAs(blob, 'readAsDataURL');
    }
  }
  // あぷ・あぷ小ファイルの文字列を見つけたらリンクに変換する(既にリンクになってたらスキップする)
  const createAnchorLink = (elms) => {
    const processNode = (node) => {
      const regex = /((?<!<a[^>]*>)(fu?)([0-9]{5,8})\.(jpe?g|png|webp|gif|bmp)(?![^<]*<\/a>))/g;
      if (node.nodeType === 3) {
        let textNode = node;
        let match;
        while ((match = regex.exec(textNode.data)) !== null) {
          const [fullMatch, _, type, digits, ext] = match;
          console.log('match:', match);
          const url =
            type === 'fu'
              ? `//dec.2chan.net/up2/src/${type}${digits}.${ext}`
              : `//dec.2chan.net/up/src/${type}${digits}.${ext}`;
          const anchor = document.createElement('a');
          anchor.href = url;
          anchor.dataset.from = 'userjs-preview';
          anchor.textContent = fullMatch;
          const nextTextNode = textNode.splitText(match.index);
          nextTextNode.data = nextTextNode.data.substring(fullMatch.length);
          textNode.parentNode.insertBefore(anchor, nextTextNode);
          textNode = nextTextNode;
        }
      } else if (node.nodeType !== 1 || node.tagName !== 'BR') {
        const childNodes = Array.from(node.childNodes);
        childNodes.forEach((childNode) => processNode(childNode));
      }
    };
    for (const el of elms) {
      processNode(el);
    }
  };
  const fetchData = (url, responseType) =>
    new Promise((resolve) => {
      let options = {
        method: 'GET',
        url,
        timeout: 10000,
        onload: (result) => {
          if (result.status === 200) {
            return resolve(result.response);
          }
          return resolve(false);
        },
        onerror: () => resolve(false),
        ontimeout: () => resolve(false),
      };
      if (typeof responseType === 'string') {
        options = {
          ...options,
          responseType,
        };
      }
      GM_xmlhttpRequest(options);
    });
  const setFailedText = (linkElm) => {
    if (linkElm && linkElm instanceof HTMLAnchorElement) {
      linkElm.insertAdjacentHTML('afterend', '<span class="userjs-preview-title">データ取得失敗</span>');
    }
  };
  const setImageElm = async (blob, linkElm) => {
    const imageMinSize = 480;
    const imageMaxSize = 1024;
    const imageEventHandler = (e) => {
      const self = e.currentTarget;
      const div = self?.parentElement;
      if (!(self instanceof HTMLImageElement) || !div) return;
      if (self.width === imageMinSize) {
        self.width = self.naturalWidth > imageMaxSize ? self.naturalWidth : imageMaxSize;
      } else {
        self.width = imageMinSize;
      }
    };
    const dataUrl = await new FileReaderEx().readAsDataURL(blob);
    const div = document.createElement('div');
    div.classList.add('userjs-preview-imageWrap');
    const img = document.createElement('img');
    img.addEventListener('load', () => {
      if (img.naturalWidth < imageMinSize) {
        img.width = img.naturalWidth;
      }
    });
    img.src = dataUrl;
    img.width = imageMinSize;
    img.classList.add('userjs-preview-image');
    div.appendChild(img);
    img.addEventListener('click', imageEventHandler);
    linkElm.insertAdjacentElement('afterend', div);
    return img;
  };
  const setLoading = (linkElm) => {
    const parentElm = linkElm.parentElement;
    if (parentElm instanceof HTMLFontElement) {
      return;
    }
    linkElm.classList.add('userjs-preview-link');
  };
  const removeLoading = (targetElm) => targetElm.classList.remove('userjs-preview-link');
  // ふたクロで「新着レスに自動スクロール」にチェックが入っている場合画像差し込み後に下までスクロールさせる
  const scrollIfAutoScrollIsEnabled = () => {
    const checkboxElm = document.querySelector('#autolive_scroll');
    const readmoreElm = document.querySelector('#res_menu');
    if (checkboxElm === null || readmoreElm === null || !checkboxElm?.checked) {
      return;
    }
    const elementHeight = readmoreElm.offsetHeight;
    const viewportHeight = window.innerHeight;
    const offsetTop = readmoreElm.offsetTop;
    window.scrollTo({
      top: offsetTop - viewportHeight + elementHeight,
      behavior: 'smooth',
    });
  };
  const insertURLData = async (linkElm) => {
    const parentElm = linkElm.parentElement;
    if (parentElm instanceof HTMLFontElement) {
      removeLoading(linkElm);
      return;
    }
    const data = await fetchData(linkElm.href, 'blob');
    if (!data) {
      setFailedText(linkElm);
      removeLoading(linkElm);
      return;
    }
    const imageElm = await setImageElm(data, linkElm);
    if (imageElm instanceof HTMLImageElement) {
      imageElm.onload = () => scrollIfAutoScrollIsEnabled();
    }
    removeLoading(linkElm);
  };
  const searchLinkElements = async (targetElm) => {
    const processBatch = async (batch) => {
      const promises = batch.map(async (linkElm) => {
        if (!linkElm.classList.contains('userjs-preview-link')) return;
        await insertURLData(linkElm);
      });
      await Promise.all(promises);
    };
    const linkElms = targetElm.querySelectorAll('a[href*="2chan.net/up"], a[href^="f"]');
    if (!linkElms.length) return;
    const regExp = /(tsumanne\.net\/si\/data|\w+\.2chan\.net\/up[0-9]?\/src)\/(.+?)\.(jpe?g|png|gif|webp)/;
    const isMatch = (ele) => regExp.test(ele.href);
    for (const linkElm of linkElms) {
      if (!isMatch(linkElm)) continue;
      setLoading(linkElm);
    }
    for (let i = 0; i < linkElms.length; i += 5) {
      const linkElm = linkElms[i];
      if (!isMatch(linkElm)) continue;
      const batch = Array.from(linkElms).slice(i, i + 5);
      await processBatch(batch);
    }
  };
  const deleteDuplicate = (blockquoteElms) => {
    for (const blockquoteElm of blockquoteElms) {
      const anchorElms = blockquoteElm.querySelectorAll('a[data-orig]');
      for (const anchorElm of anchorElms) {
        const newAnchorElm = anchorElm.querySelector('a[data-from]');
        if (newAnchorElm !== null) {
          anchorElm.outerHTML = newAnchorElm.outerHTML;
        }
      }
    }
  };
  const mutationLinkElements = async (mutations) => {
    for (const mutation of mutations) {
      for (const addedNode of mutation.addedNodes) {
        if (!(addedNode instanceof HTMLElement)) continue;
        const newBlockQuotes = addedNode.querySelectorAll('blockquote');
        createAnchorLink(newBlockQuotes);
        deleteDuplicate(newBlockQuotes);
        searchLinkElements(addedNode);
      }
    }
  };
  // ふたクロが無い環境用にアンカーリンクを生成したい
  const exec = () => {
    const threadElm = document.querySelector('.thre');
    const futakuroElm = document.querySelector('#fvw_menu');
    if (!initExecCreateLink && threadElm instanceof HTMLElement && futakuroElm === null) {
      const quoteElms = threadElm.querySelectorAll('blockquote');
      initExecCreateLink = true;
      if (initTimer) {
        clearTimeout(initTimer);
      }
      createAnchorLink(quoteElms);
      searchLinkElements(threadElm);
    }
  };
  let threadElm = document.querySelector('.thre');
  if (threadElm instanceof HTMLElement) {
    searchLinkElements(threadElm);
    const observer = new MutationObserver(mutationLinkElements);
    observer.observe(threadElm, {
      childList: true,
    });
    initTimer = setTimeout(exec, 1500);
  }
})();

QingJ © 2025

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