buyNow!

ふたばちゃんねるのスレッド上で貼られた特定のECサイトのURLからタイトルとあれば価格と画像を取得する

当前为 2025-03-16 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         buyNow!
// @namespace    http://2chan.net/
// @version      0.8.13
// @description  ふたばちゃんねるのスレッド上で貼られた特定のECサイトのURLからタイトルとあれば価格と画像を取得する
// @author       ame-chan
// @match        http://*.2chan.net/b/res/*
// @match        https://*.2chan.net/b/res/*
// @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
// @connect      amazon.co.jp
// @connect      www.amazon.co.jp
// @connect      amzn.to
// @connect      amzn.asia
// @connect      media-amazon.com
// @connect      m.media-amazon.com
// @connect      dlsite.com
// @connect      img.dlsite.jp
// @connect      bookwalker.jp
// @connect      c.bookwalker.jp
// @connect      store.steampowered.com
// @connect      cdn.akamai.steamstatic.com
// @connect      cdn.cloudflare.steamstatic.com
// @connect      store.cloudflare.steamstatic.com
// @connect      youtube.com
// @connect      youtu.be
// @connect      nintendo.com
// @connect      store-jp.nintendo.com
// @connect      dmm.co.jp
// @connect      www.dmm.co.jp
// @connect      dlsoft.dmm.co.jp
// @connect      pics.dmm.co.jp
// @connect      doujin-assets.dmm.co.jp
// @license      MIT
// ==/UserScript==
(function () {
  'use strict';
  const WHITE_LIST_DOMAINS = [
    'amazon.co.jp',
    'amzn.to',
    'amzn.asia',
    'dlsite.com',
    'bookwalker.jp',
    'store.steampowered.com',
    'youtube.com',
    'youtu.be',
    'store-jp.nintendo.com',
    'dlsoft.dmm.co.jp',
    'www.dmm.co.jp',
  ];
  const WHITE_LIST_SELECTORS = (() => WHITE_LIST_DOMAINS.map((domain) => `a[href*="${domain}"]`).join(','))();
  const convertHostname = (path) => new URL(path).hostname;
  const isAmazon = (path) => /^(www\.)?amazon.co.jp|amzn\.to|amzn\.asia$/.test(convertHostname(path));
  const isDLsite = (path) => /^(www\.)?dlsite\.com$/.test(convertHostname(path));
  const isBookwalker = (path) => /^(www\.)?bookwalker.jp$/.test(convertHostname(path));
  const isSteam = (path) => /^store\.steampowered\.com$/.test(convertHostname(path));
  const isYouTube = (path) => /^youtu\.be|(www\.)?youtube.com$/.test(convertHostname(path));
  const isNintendoStore = (path) => /^store-jp\.nintendo\.com$/.test(convertHostname(path));
  const isFanzaDoujin = (path) => /^(www\.)?dmm\.co\.jp$/.test(convertHostname(path));
  const isFanzaDlsoft = (path) => /^dlsoft\.dmm\.co\.jp$/.test(convertHostname(path));
  const isProductPage = (url) =>
    /^https?:\/\/(?:www\.)?amazon(.+?)\/(?:exec\/obidos\/ASIN|gp\/product|gp\/aw\/d|o\/ASIN|(?:.+?\/)?dp|d)\/[A-Z0-9]{10}/.test(
      url,
    ) ||
    /^https?:\/\/amzn.(asia|to)\//.test(url) ||
    /^https?:\/\/(www\.)?dlsite\.com\/.+?\/[A-Z0-9]{8,}(\.html)?/.test(url) ||
    /^https?:\/\/(www\.)?bookwalker\.jp\/[a-z0-9]{10}\-[a-z0-9]{4}\-[a-z0-9]{4}\-[a-z0-9]{4}\-[a-z0-9]{12}/.test(url) ||
    /^https?:\/\/(www\.)?bookwalker\.jp\/(series|tag)\/[0-9]+\//.test(url) ||
    /^https?:\/\/store.steampowered.com\/(agecheck\/)?app\/\d+/.test(url) ||
    /^https?:\/\/(youtu\.be\/|((www|m)\.)?youtube.com\/(watch\?v=|live\/))\w+/.test(url) ||
    /^https?:\/\/store-jp\.nintendo\.com\/list\/software\/(D)?[0-9]+.html/.test(url) ||
    /^https?:\/\/dlsoft\.dmm\.co\.jp\/(list|detail)\/.+?/.test(url) ||
    /^https?:\/\/(www\.)?dmm\.co\.jp\/dc\/doujin\/.+?/.test(url);
  const getBrandName = (url) => {
    if (isAmazon(url)) {
      return 'amazon';
    } else if (isDLsite(url)) {
      return 'dlsite';
    } else if (isBookwalker(url)) {
      return 'bookwalker';
    } else if (isSteam(url)) {
      return 'steam';
    } else if (isYouTube(url)) {
      return 'youtube';
    } else if (isNintendoStore(url)) {
      return 'nintendo';
    } else if (isFanzaDlsoft(url)) {
      return 'fanzaDlsoft';
    } else if (isFanzaDoujin(url)) {
      return 'fanzaDoujin';
    }
    return '';
  };
  const getOgpImage = (targetDocument) => targetDocument.querySelector('meta[property="og:image"]')?.content || '';
  const getSelectorConditions = {
    amazon: {
      price: (targetDocument) => {
        const priceRange = () => {
          const rangeElm = targetDocument.querySelector('.a-price-range');
          if (!rangeElm) return 0;
          rangeElm.querySelectorAll('.a-offscreen').forEach((el) => el.remove());
          return rangeElm.textContent?.replace(/[\s]+/g, '');
        };
        try {
          const price =
            targetDocument.querySelector('#twister-plus-price-data-price')?.value ||
            targetDocument.querySelector('#kindle-price')?.textContent?.replace(/[\s¥,]+/g, '') ||
            targetDocument.querySelector('[name="displayedPrice"]')?.value ||
            targetDocument.querySelector('.a-price-whole')?.textContent?.replace(/[\s¥,]+/g, '');
          return Math.round(Number(price)) || priceRange() || 0;
        } catch (e) {
          return 0;
        }
      },
      image: (targetDocument) =>
        targetDocument.querySelector('#landingImage')?.src ||
        targetDocument.querySelector('.unrolledScrollBox li:first-child img')?.src ||
        targetDocument.querySelector('[data-a-image-name]')?.src ||
        targetDocument.querySelector('#imgBlkFront')?.src,
      title: (targetDocument) =>
        targetDocument.querySelector('#productTitle')?.textContent ||
        targetDocument.querySelector('#title')?.textContent ||
        targetDocument.querySelector('#landingImage')?.alt ||
        targetDocument.querySelector('.unrolledScrollBox li:first-child img')?.alt ||
        targetDocument.querySelector('[data-a-image-name]')?.alt ||
        targetDocument.querySelector('#imgBlkFront')?.alt,
    },
    dlsite: {
      price: (targetDocument) => {
        try {
          const url = targetDocument.querySelector('meta[property="og:url"]')?.content;
          const productId = url.split('/').pop()?.replace('.html', '');
          const priceElm = targetDocument.querySelector(`[data-product_id="${productId}"][data-price]`);
          return parseInt(priceElm?.getAttribute('data-price') || '0', 10);
        } catch (e) {
          return 0;
        }
      },
      image: getOgpImage,
    },
    bookwalker: {
      price: (targetDocument) => {
        try {
          const price =
            Number(targetDocument.querySelector('.t-c-sales-price__value')?.textContent?.replace(/[^0-9]/g, '')) ||
            Number(
              targetDocument
                .querySelector('.m-tile-list .m-tile .m-book-item__price-num')
                ?.textContent?.replace(/,/g, ''),
            ) ||
            Number(targetDocument.querySelector('#jsprice')?.textContent?.replace(/[円,]/g, ''));
          return Number.isInteger(price) && price > 0 ? price : 0;
        } catch (e) {
          return 0;
        }
      },
      image: (targetDocument) =>
        targetDocument.querySelector('.m-tile-list .m-tile img')?.getAttribute('data-original') ||
        getOgpImage(targetDocument),
    },
    steam: {
      price: (targetDocument) => {
        try {
          const elm =
            targetDocument.querySelector('.game_area_purchase_game_wrapper .game_purchase_price.price') ||
            targetDocument.querySelector('.game_area_purchase_game .game_purchase_price.price') ||
            targetDocument.querySelector('.game_area_purchase_game_wrapper .discount_final_price');
          const price = elm?.firstChild?.textContent?.replace(/[¥,\s\t\r\n]+/g, '');
          const isComingSoon = targetDocument.querySelector('.game_area_comingsoon');
          const isAgeCheck = targetDocument.querySelector('#app_agegate');
          const num = Number(price);
          if (isAgeCheck) {
            return 'ログインか年齢確認が必要です';
          } else if (isComingSoon) {
            return '近日登場';
          } else if (Number.isInteger(num) && num > 0) {
            return num;
          } else if (typeof price === 'string') {
            return price;
          }
          return 0;
        } catch (e) {
          return 0;
        }
      },
      image: getOgpImage,
    },
    nintendo: {
      price: (targetDocument) => {
        try {
          const priceElm = targetDocument.querySelector('.js-productMainRenderedPrice > span:first-of-type');
          const priceText = priceElm?.textContent?.replace(/,/g, '');
          const price = Number(priceText);
          if (Number.isInteger(price) && price > 0) {
            return price;
          } else if (typeof priceText === 'string') {
            return priceText;
          }
          return 0;
        } catch (e) {
          return 0;
        }
      },
      image: getOgpImage,
    },
    fanzaDlsoft: {
      price: (targetDocument) => {
        try {
          const priceElm =
            targetDocument.querySelector('.tx-bskt-price') ||
            targetDocument.querySelector('.sellingPrice__discountedPrice');
          const priceText = priceElm?.textContent?.replace(/[,円]/g, '');
          const price = Number(priceText);
          if (Number.isInteger(price) && price > 0) {
            return price;
          } else if (typeof priceText === 'string') {
            return priceText;
          }
          return 0;
        } catch (e) {
          return 0;
        }
      },
      image: (targetDocument) => {
        return getOgpImage(targetDocument) || targetDocument.querySelector('.d-item #list .img img')?.src || '';
      },
    },
    fanzaDoujin: {
      price: (targetDocument) => {
        try {
          const priceText =
            targetDocument.querySelector('p.priceList__main')?.textContent?.replace(/[,円]/g, '') ||
            targetDocument.querySelector('.purchase__btn')?.getAttribute('data-price');
          const price = Number(priceText);
          if (Number.isInteger(price) && price > 0) {
            return price;
          } else if (typeof priceText === 'string') {
            return priceText;
          }
          return 0;
        } catch (e) {
          return 0;
        }
      },
      image: (targetDocument) => {
        return (
          getOgpImage(targetDocument) || targetDocument.querySelector('.productList .tileListImg__tmb img')?.src || ''
        );
      },
    },
    // 画像のみ取得
    youtube: {
      price: () => 0,
      image: getOgpImage,
    },
  };
  const addedStyle = `<style id="userjs-buyNow-style">
  .userjs-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;
  }
  .userjs-title-inner {
    display: flex;
    flex-direction: column;
    gap: 8px;
    line-height: 1.6 !important;
    color: #ff3860 !important;
  }
  .userjs-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-imageWrap {
    width: 150px;
  }
  .userjs-imageWrap.-center {
    text-align: center;
  }
  .userjs-imageWrap.-large {
    width: 600px;
  }
  .userjs-image {
    max-width: none !important;
    max-height: none !important;
    transition: all 0.2s ease-in-out;
    border-radius: 4px;
  }
  .userjs-price {
    display: block;
    color: #228b22 !important;
    font-weight: 700;
  }
  </style>`;
  if (!document.querySelector('#userjs-buyNow-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 fetchData = (url, responseType) =>
    new Promise((resolve) => {
      let options = {
        method: 'GET',
        url,
        timeout: 10000,
        onload: (result) => {
          if (result.status === 200 || result.status === 404) {
            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-title">データ取得失敗</span>');
    }
  };
  const getPriceText = (price) => {
    let priceText = price;
    if (!price) return '';
    if (typeof price === 'number' && Number.isInteger(price) && price > 0) {
      priceText = new Intl.NumberFormat('ja-JP', {
        style: 'currency',
        currency: 'JPY',
      }).format(price);
    }
    return `<span class="userjs-price">${priceText}</span>`;
  };
  const setTitleText = ({ targetDocument, selectorCondition, linkElm, brandName }) => {
    let titleElm = targetDocument.querySelector('title');
    let title = titleElm?.textContent ?? '';
    // Amazonはtitleタグが無い場合がある
    if (title === '' && brandName === 'amazon') {
      title = selectorCondition.title(targetDocument);
    }
    if (!title) {
      setFailedText(linkElm);
      return;
    }
    const price = selectorCondition.price(targetDocument);
    const priceText = getPriceText(price);
    const nextSibling = linkElm.nextElementSibling;
    if (nextSibling && nextSibling instanceof HTMLElement && nextSibling.tagName.toLowerCase() === 'br') {
      nextSibling.style.display = 'none';
    }
    if (title === 'サイトエラー') {
      const errorText = targetDocument.querySelector('#error_box')?.textContent;
      if (errorText) {
        title = errorText;
      }
    }
    if (linkElm && linkElm instanceof HTMLAnchorElement) {
      linkElm.insertAdjacentHTML(
        'afterend',
        `<div class="userjs-title">
        <span class="userjs-title-inner">${title}${priceText}</span>
      </div>`,
      );
    }
  };
  const setImageElm = async ({ imagePath, titleTextElm }) => {
    const imageMinSize = 150;
    const imageMaxSize = 600;
    const imageEventHandler = (e) => {
      const self = e.currentTarget;
      const div = self?.parentElement;
      if (!(self instanceof HTMLImageElement) || !div) return;
      if (self.width === imageMinSize) {
        div.classList.remove('-center');
        div.classList.add('-large');
        self.width = imageMaxSize;
      } else {
        div.classList.remove('-large');
        if (self.naturalWidth > imageMinSize) {
          self.width = imageMinSize;
        } else {
          div.classList.add('-center');
          self.width = self.naturalWidth;
        }
      }
    };
    const blob = await fetchData(imagePath, 'blob');
    const titleInnerElm = titleTextElm.querySelector('.userjs-title-inner');
    if (!blob || !titleInnerElm) return false;
    const dataUrl = await new FileReaderEx().readAsDataURL(blob);
    const div = document.createElement('div');
    div.classList.add('userjs-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-image');
    div.appendChild(img);
    img.addEventListener('click', imageEventHandler);
    titleInnerElm.insertAdjacentElement('beforebegin', div);
    return img;
  };
  const setLoading = (linkElm) => {
    const parentElm = linkElm.parentElement;
    if (parentElm instanceof HTMLFontElement || !isProductPage(linkElm.href)) {
      return;
    }
    linkElm.classList.add('userjs-link');
  };
  const removeLoading = (targetElm) => targetElm.classList.remove('userjs-link');
  // AgeCheck
  const isAgeCheck = (targetDocument, selector) => targetDocument.querySelector(selector) !== null;
  const getAgeCheckConfirmAdultPageHref = ({ targetDocument, selector, domain = '' }) => {
    const yesBtnLinkElm = targetDocument.querySelector(selector);
    if (yesBtnLinkElm instanceof HTMLAnchorElement) {
      return `${domain}${yesBtnLinkElm.getAttribute('href')}`;
    }
    return false;
  };
  const getAgeCheckPassedAdultDocument = async ({ targetDocument, linkElm, parser, selector, domain = '' }) => {
    const newHref = getAgeCheckConfirmAdultPageHref({
      targetDocument,
      selector,
      domain,
    });
    const htmlData = newHref && (await fetchData(newHref));
    if (!htmlData) {
      setFailedText(linkElm);
      removeLoading(linkElm);
      return false;
    }
    return parser.parseFromString(htmlData, 'text/html');
  };
  const getNewDocument = async ({ targetDocument, linkElm, parser, brandName }) => {
    const domain = brandName === 'amazon' ? 'https://www.amazon.co.jp' : '';
    const selector = (() => {
      switch (brandName) {
        case 'amazon':
          return '#black-curtain-yes-button a';
        case 'fanzaDlsoft':
        case 'fanzaDoujin':
          return '.ageCheck__link.ageCheck__link--r18';
        default:
          return false;
      }
    })();
    if (selector) {
      const newDocument = await getAgeCheckPassedAdultDocument({
        targetDocument,
        linkElm,
        parser,
        selector,
        domain,
      });
      if (newDocument) {
        return newDocument;
      }
    }
    return false;
  };
  // ふたクロで「新着レスに自動スクロール」にチェックが入っている場合画像差し込み後に下までスクロールさせる
  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',
    });
  };
  // AmazonURLの正規化(amzn.toやamzn.asiaなど)
  const canonicalizeAmazonURL = (targetDocument, linkElm) => {
    const scriptElms = targetDocument.querySelectorAll('script');
    let asin = '';
    for (const scriptElm of scriptElms) {
      const text = scriptElm.textContent;
      if (text && text.includes('var opts')) {
        [, asin] = text.match(/asin:\s?\"(.+?)\"/) || [];
        break;
      }
    }
    if (asin && asin.length) {
      linkElm.href = `https://www.amazon.co.jp/dp/${asin}`;
      linkElm.textContent = `https://www.amazon.co.jp/dp/${asin}`;
    }
  };
  const insertURLData = async (linkElm) => {
    const parentElm = linkElm.parentElement;
    if (parentElm instanceof HTMLFontElement || !isProductPage(linkElm.href)) {
      removeLoading(linkElm);
      return;
    }
    const brandName = getBrandName(linkElm.href);
    if (brandName === '') {
      setFailedText(linkElm);
      removeLoading(linkElm);
      return;
    }
    const htmlData = await fetchData(linkElm.href);
    if (!htmlData) {
      setFailedText(linkElm);
      removeLoading(linkElm);
      return;
    }
    const adultPageLists = ['amazon', 'fanzaDlsoft', 'fanzaDoujin'];
    const parser = new DOMParser();
    let targetDocument = parser.parseFromString(htmlData, 'text/html');
    // AmazonやFANZAのアダルトページ確認画面スキップ
    if (adultPageLists.includes(brandName)) {
      const is18xAmazon = isAgeCheck(targetDocument, '#black-curtain-warning');
      const is18xFanza = isAgeCheck(targetDocument, '.ageCheck');
      if (is18xAmazon || is18xFanza) {
        const newDocument = await getNewDocument({
          targetDocument,
          linkElm,
          parser,
          brandName,
        });
        if (newDocument) {
          targetDocument = newDocument;
        }
      }
    }
    const selectorCondition = getSelectorConditions[brandName];
    setTitleText({
      targetDocument,
      selectorCondition,
      linkElm,
      brandName,
    });
    const titleTextElm = linkElm.nextElementSibling;
    const imagePath = selectorCondition.image(targetDocument);
    if (imagePath && titleTextElm) {
      const imageElm = await setImageElm({
        imagePath,
        titleTextElm,
      });
      if (imageElm instanceof HTMLImageElement) {
        imageElm.onload = () => scrollIfAutoScrollIsEnabled();
      }
    } else {
      const hasFailedElm = linkElm.nextElementSibling?.classList.contains('userjs-title');
      if (!hasFailedElm) {
        setFailedText(linkElm);
      }
    }
    if (brandName === 'amazon') {
      canonicalizeAmazonURL(targetDocument, linkElm);
    }
    removeLoading(linkElm);
  };
  const replaceDefaultURL = (targetElm) => {
    const linkElms = targetElm.querySelectorAll('a[href]');
    const replaceUrl = (url) => {
      const regex = /http:\/\/www\.dlsite\.com\/(.+?)\/dlaf\/=\/link\/work\/aid\/[a-zA-Z]+\/id\/(RJ[0-9]+)\.html/;
      const newUrlFormat = 'https://www.dlsite.com/$1/work/=/product_id/$2.html';
      return url.replace(regex, newUrlFormat);
    };
    const decodeHtmlEntities = (text) => {
      return text.replace(/&#(\d+);/g, (_, dec) => {
        return String.fromCharCode(dec);
      });
    };
    for (const linkElm of linkElms) {
      const brandName = getBrandName(linkElm.href);
      const href = linkElm.getAttribute('href');
      if (brandName === 'dlsite') {
        linkElm.href = decodeHtmlEntities(decodeURIComponent(replaceUrl(href.replace('/bin/jump.php?', ''))));
      } else {
        linkElm.href = decodeHtmlEntities(decodeURIComponent(href.replace('/bin/jump.php?', '')));
      }
    }
  };
  const processingQueue = [];
  let activeRequests = 0;
  const MAX_CONCURRENT_REQUESTS = 3;
  const processQueue = async () => {
    while (activeRequests < MAX_CONCURRENT_REQUESTS && processingQueue.length > 0) {
      const linkElm = processingQueue.shift();
      if (linkElm) {
        activeRequests++;
        insertURLData(linkElm).finally(() => {
          activeRequests--;
          processQueue();
        });
      }
    }
  };
  const observeLinkElements = (linkElms) => {
    const winH = window.innerHeight;
    const observerOptions = {
      root: null,
      // ビューポートの上下にビューポートの高さ分のマージンを持たせる
      rootMargin: `${winH}px 0px`,
      threshold: 0,
    };
    const observer = new IntersectionObserver(async (entries, observer) => {
      for (const entry of entries) {
        if (entry.isIntersecting) {
          const linkElm = entry.target;
          observer.unobserve(linkElm);
          // 見えるようになったリンクを処理キューに追加
          processingQueue.push(linkElm);
          processQueue();
        }
      }
    }, observerOptions);
    linkElms.forEach((linkElm) => observer.observe(linkElm));
  };
  const searchLinkElements = async (targetElm) => {
    const linkElms = targetElm.querySelectorAll(WHITE_LIST_SELECTORS);
    if (!linkElms.length) return;
    for (const linkElm of linkElms) {
      setLoading(linkElm);
    }
    observeLinkElements(Array.from(linkElms));
  };
  const mutationLinkElements = async (mutations) => {
    for (const mutation of mutations) {
      for (const addedNode of mutation.addedNodes) {
        if (!(addedNode instanceof HTMLElement)) continue;
        replaceDefaultURL(addedNode);
        searchLinkElements(addedNode);
      }
    }
  };
  const threadElm = document.querySelector('.thre');
  if (threadElm instanceof HTMLElement) {
    replaceDefaultURL(threadElm);
    searchLinkElements(threadElm);
    const observer = new MutationObserver(mutationLinkElements);
    observer.observe(threadElm, {
      childList: true,
    });
  }
})();