// ==UserScript==
// @name buyNow!
// @namespace http://2chan.net/
// @version 0.4.4
// @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 img.dlsite.jp
// @connect bookwalker.jp
// @connect c.bookwalker.jp
// @connect store.steampowered.com
// @connect cdn.cloudflare.steamstatic.com
// @connect store.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 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,
},
};
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 {
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 {
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;
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;
}
}
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;
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 div = document.createElement('div');
div.classList.add('userjs-imageWrap');
const img = document.createElement('img');
img.src = dataUrl;
img.width = imageMinSize;
img.classList.add('userjs-image');
div.appendChild(img);
img.addEventListener('click', imageEventHandler);
titleTextElm.querySelector('.userjs-title-inner')?.insertAdjacentElement('beforebegin', div);
};
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,
});
}
})();