buyNow!

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

目前為 2023-04-23 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 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.4.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      dlsite.com
// @connect      bookwalker.jp
// @connect      c.bookwalker.jp
// @connect      store.steampowered.com
// @connect      cdn.cloudflare.steamstatic.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',
  ];
  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 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);
  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';
    }
    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 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 num = Number(price);
        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,
    },
  };
  const addedStyle = `<style id="userjs-buyNow-style">
  .userjs-title {
    display: flex;
    flex-direction: row;
    margin: 8px 0 16px;
    padding: 16px;
    line-height: 1.6 !important;
    color: #ff3860 !important;
    background-color: #fff;
    border-radius: 4px;
  }
  .userjs-title-inner {
    width: 400px;
  }
  .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-image {
    margin-right: 16px;
    max-width: none !important;
    max-height: none !important;
    transition: all 0.3s ease-in-out;
    border-radius: 4px;
  }
  .userjs-price {
    display: block;
    margin-top: 4px;
    color: #228b22 !important;
    font-weight: 700;
  }
  [data-id="userjs-loading"] {
    margin-left: 4px;
  }
  </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) => {
    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;
    if (nextSibling && nextSibling instanceof HTMLElement && nextSibling.tagName.toLowerCase() === 'br') {
      nextSibling.style.display = 'none';
    }
    linkElm?.insertAdjacentHTML(
      'afterend',
      `<div class="userjs-title">
      <span class="userjs-title-inner">${titleElm.textContent}${priceText}</span>
    </div>`,
    );
  };
  const setImageElm = async ({ imagePath, titleTextElm }) => {
    const imageMinSize = 150;
    const imageMaxSize = 600;
    const imageEventHandler = (e) => {
      const self = e.currentTarget;
      if (!(self instanceof HTMLImageElement)) return;
      if (self.width === imageMinSize) {
        self.width = imageMaxSize;
      } else {
        self.width = imageMinSize;
      }
    };
    const blob = await fetchData(imagePath, 'blob');
    if (!blob) return;
    const dataUrl = await new FileReaderEx().readAsDataURL(blob);
    const img = document.createElement('img');
    img.src = dataUrl;
    img.width = imageMinSize;
    img.classList.add('userjs-image');
    titleTextElm.querySelector('.userjs-title-inner')?.insertAdjacentElement('beforebegin', img);
    img.addEventListener('click', imageEventHandler);
  };
  const setLoading = (linkElm) => {
    const parentElm = linkElm.parentElement;
    if (
      parentElm instanceof HTMLFontElement ||
      !isProductPage(linkElm.href) ||
      parentElm?.querySelector('[data-id="userjs-loading"]')
    ) {
      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 insertURLData = async (linkElm) => {
    const parentElm = linkElm.parentElement;
    if (parentElm instanceof HTMLFontElement || !isProductPage(linkElm.href)) {
      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 (isAmazonConfirmAdultPage(targetDocument)) {
      const amazonAdultDocument = await getAmazonAdultDocument(targetDocument, linkElm, parser);
      if (amazonAdultDocument) {
        targetDocument = amazonAdultDocument;
      }
    }
    const brandName = getBrandName(linkElm.href);
    if (brandName === '') {
      setFailedText(linkElm);
      removeLoading(linkElm);
      return;
    }
    const selectorCondition = getSelectorConditions[brandName];
    setTitleText({
      targetDocument,
      selectorCondition,
      linkElm,
    });
    const titleTextElm = linkElm.nextElementSibling;
    const imagePath = selectorCondition.image(targetDocument);
    if (imagePath && titleTextElm) {
      await setImageElm({
        imagePath,
        titleTextElm,
      });
    }
    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 = (targetElm) => {
    const linkElms = targetElm.querySelectorAll(WHITE_LIST_SELECTORS);
    if (!linkElms.length) return;
    for (const linkElm of linkElms) {
      if (!(linkElm instanceof HTMLElement)) continue;
      setLoading(linkElm);
      void insertURLData(linkElm);
    }
  };
  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,
    });
  }
})();