buyNow!

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

目前為 2023-08-11 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         buyNow!
// @namespace    http://2chan.net/
// @version      0.7.0
// @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.cloudflare.steamstatic.com
// @connect      store.cloudflare.steamstatic.com
// @connect      youtube.com
// @connect      youtu.be
// @connect      nintendo.com
// @connect      store-jp.nintendo.com
// @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',
  ];
  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 isProductPage = (url) =>
    /^https?:\/\/(www\.)?amazon\.co\.jp\/.*\/[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\/[0-9]+\/list/.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\/[0-9]+.html/.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';
    }
    return '';
  };
  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, '');
        };
        const price =
          targetDocument.querySelector('#twister-plus-price-data-price')?.value ||
          targetDocument.querySelector('#kindle-price')?.textContent?.replace(/[\s¥,]+/g, '') ||
          targetDocument.querySelector('[name="displayedPrice"]')?.value;
        return Math.round(Number(price)) || priceRange() || 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,
    },
    dlsite: {
      price: (targetDocument) => {
        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);
      },
      image: (targetDocument) => targetDocument.querySelector('meta[property="og:image"]')?.content,
    },
    bookwalker: {
      price: (targetDocument) => {
        const price =
          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;
      },
      image: (targetDocument) =>
        targetDocument.querySelector('.m-tile-list .m-tile img')?.getAttribute('data-original') ||
        targetDocument.querySelector('meta[property="og:image"]')?.content,
    },
    steam: {
      price: (targetDocument) => {
        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;
      },
      image: (targetDocument) => targetDocument.querySelector('meta[property="og:image"]')?.content,
    },
    nintendo: {
      price: (targetDocument) => {
        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;
      },
      image: (targetDocument) => targetDocument.querySelector('meta[property="og:image"]')?.content,
    },
    // 画像のみ取得
    youtube: {
      price: () => 0,
      image: (targetDocument) => targetDocument.querySelector('meta[property="og:image"]')?.content,
    },
  };
  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) {
            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 }) => {
    const titleElm = targetDocument.querySelector('title');
    if (!titleElm || !titleElm?.textContent) return;
    const price = selectorCondition.price(targetDocument);
    const priceText = getPriceText(price);
    const nextSibling = linkElm.nextElementSibling;
    let title = titleElm.textContent;
    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');
  const isAmazonConfirmAdultPage = (targetDocument) => targetDocument.querySelector('#black-curtain-warning') !== null;
  const getAmazonConfirmAdultPageHref = (targetDocument) => {
    const yesBtnLinkElm = targetDocument.querySelector('#black-curtain-yes-button a');
    if (yesBtnLinkElm instanceof HTMLAnchorElement) {
      return `https://www.amazon.co.jp${yesBtnLinkElm.getAttribute('href')}`;
    }
    return false;
  };
  const getAmazonAdultDocument = async (targetDocument, linkElm, parser) => {
    const newHref = getAmazonConfirmAdultPageHref(targetDocument);
    const htmlData = newHref && (await fetchData(newHref));
    if (!htmlData) {
      setFailedText(linkElm);
      removeLoading(linkElm);
      return false;
    }
    return parser.parseFromString(htmlData, 'text/html');
  };
  // ふたクロで「新着レスに自動スクロール」にチェックが入っている場合画像差し込み後に下までスクロールさせる
  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 || !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 parser = new DOMParser();
    let targetDocument = parser.parseFromString(htmlData, 'text/html');
    // アダルトページ確認画面スキップ
    if (isAmazon(linkElm.href) && isAmazonConfirmAdultPage(targetDocument)) {
      const amazonAdultDocument = await getAmazonAdultDocument(targetDocument, linkElm, parser);
      if (amazonAdultDocument) {
        targetDocument = amazonAdultDocument;
      }
    }
    const selectorCondition = getSelectorConditions[brandName];
    setTitleText({
      targetDocument,
      selectorCondition,
      linkElm,
    });
    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();
      }
    }
    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);
    };
    for (const linkElm of linkElms) {
      const brandName = getBrandName(linkElm.href);
      const href = linkElm.getAttribute('href');
      if (brandName === 'dlsite') {
        linkElm.href = replaceUrl(href.replace('/bin/jump.php?', ''));
      } else {
        linkElm.href = href.replace('/bin/jump.php?', '');
      }
    }
  };
  const searchLinkElements = async (targetElm) => {
    const linkElms = targetElm.querySelectorAll(WHITE_LIST_SELECTORS);
    if (!linkElms.length) return;
    const processBatch = async (batch) => {
      const promises = batch.map(async (linkElm) => {
        if (!linkElm.classList.contains('userjs-link')) return;
        await insertURLData(linkElm);
      });
      await Promise.all(promises);
    };
    for (const linkElm of linkElms) {
      setLoading(linkElm);
    }
    for (let i = 0; i < linkElms.length; i += 5) {
      const batch = Array.from(linkElms).slice(i, i + 5);
      await processBatch(batch);
    }
  };
  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,
    });
  }
})();