// ==UserScript==
// @name Show points on Amazon.co.jp wishlist
// @version 20.5.0
// @description Amazon.co.jpの欲しいものリストと検索ページで、Kindleの商品にポイントを表示しようとします
// @namespace https://greasyfork.org/ja/users/165645-agn5e3
// @author Nathurru
// @match https://www.amazon.co.jp/*/wishlist/*
// @match https://www.amazon.co.jp/wishlist/*
// @match https://www.amazon.co.jp/*/dp/*
// @match https://www.amazon.co.jp/dp/*
// @match https://www.amazon.co.jp/*/gp/*
// @match https://www.amazon.co.jp/gp/*
// @match https://www.amazon.co.jp/s*
// @match https://www.amazon.co.jp/b*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_xmlhttpRequest
// @compatible Chrome
// @license Apache-2.0
// @downloadURL none
// ==/UserScript==
/**********************************************************************************************************************
NOTICE:このアプリケーションは国立国会図書館サーチAPI( https://iss.ndl.go.jp/information/api/ )を利用しています
**********************************************************************************************************************/
(function () {
'use strict';
const domParser = new DOMParser();
const CACHE_LIFETIME = 1209600000;
const RESCAN_INTERVAL = 10800000;
const AUTOMATIC_CLEAN_FACTOR = 100;
const TAX = 0.1;
const taxIncluded = listPrice => Math.floor(listPrice * (1 + TAX));
const isNull = value => value === null;
const isUndefined = value => value === undefined;
const rate = ((numerator, denominator) => denominator === 0 ? 0 : Math.ceil(numerator / denominator * 100));
const random = max => Math.floor(Math.random() * Math.floor(max));
const sleep = ms => new Promise((resolve) => setTimeout(resolve, ms));
const rateColor = rate => {
if (rate < 20) {
return {
color: 'initial',
bgColor: 'initial',
};
} else if (rate < 50) {
return {
color: 'initial',
bgColor: '#F7D44A',
};
} else if (rate < 80) {
return {
color: '#FFFFFF',
bgColor: '#FE7E03',
};
} else {
return {
color: '#FFFFFF',
bgColor: '#9B1D1E',
};
}
};
const url = {
ndl(isbn) {
return 'https://iss.ndl.go.jp/api/sru?operation=searchRetrieve&recordSchema=dcndl&recordPacking=xml&query=isbn=' + isbn;
},
amazon(asin) {
return 'https://www.amazon.co.jp/dp/' + asin;
},
}
const storage = {
async save(key, data) {
console.log('SAVE: ' + key, data);
GM_setValue(key, JSON.stringify(data));
},
load(key) {
const data = GM_getValue(key);
if (isUndefined(data)) {
return null;
}
console.log('LOAD: ' + key, data);
return JSON.parse(data);
},
exists(key) {
return !isUndefined(GM_getValue(key));
},
async delete(key) {
console.log('DELETE: ' + key);
GM_deleteValue(key);
},
list() {
return GM_listValues();
},
clean() {
console.log('CLEANING');
const keys = this.list();
const now = Date.now();
for (const key of keys) {
const data = this.load(key);
if (now - data.updatedAt > CACHE_LIFETIME) {
this.delete(key);
}
}
},
isCacheActive(asin) {
if (!storage.exists(asin)) {
return false;
} else {
return Date.now() - storage.load(asin).updatedAt <= RESCAN_INTERVAL;
}
},
}
const storageClean = (() => {
if (random(AUTOMATIC_CLEAN_FACTOR) === 0) {
storage.clean();
}
})
const isIsbn = ((isbn) => {
let c = 0;
if (isbn.match(/^4[0-9]{8}[0-9X]?$/)) {
for (let i = 0; i < 9; ++i) {
c += (10 - i) * Number(isbn.charAt(i));
}
c = (11 - c % 11) % 11;
c = (c === 10) ? 'X' : String(c);
return c === isbn.charAt(9);
} else if (isbn.match(/^9784[0-9]{9}?$/)) {
for (let i = 0; i < 12; ++i) {
c += Number(isbn.charAt(i)) * ((i % 2) ? 3 : 1);
}
c = ((10 - c % 10) % 10);
return String(c) === isbn.charAt(12);
} else {
return false;
}
});
const get = (async (URL) => {
console.log('GET: ' + URL);
return new Promise((resolve, reject) => {
const xhr = window.GM_xmlhttpRequest;
xhr({
onabort: reject,
onerror: reject,
onload: resolve,
ontimeout: reject,
method: 'GET',
url: URL,
withCredentials: true,
});
});
});
const parser = {
async isKindlePage(dom) {
const element = dom.querySelector('#title');
if (isNull(element)) {
return false;
}
return /kindle版/i.test(element.innerText)
},
async isbns(dom) {
let isbns = [];
const elements = dom.querySelectorAll('li.swatchElement .a-button a');
for (const element of elements) {
const href = element.getAttribute("href");
if (isNull(href)) {
continue;
}
const m = href.match(/\/(4[0-9]{8}[0-9X])/);
if (!isNull(m) && isIsbn(m[1])) {
isbns.push(m[1]);
}
}
return isbns;
},
async isBought(dom) {
const element = dom.querySelector('#ebooksInstantOrderUpdate_feature_div');
if (isNull(element)) {
return false;
}
return /商品を注文/.test(element.innerText);
},
async asin(dom) {
const element = dom.querySelector("input[name='ASIN.0']");
if (isNull(element)) {
return null;
}
return element.value;
},
async kindlePrice(dom) {
const element = dom.querySelector(".kindle-price");
if (isNull(element)) {
return 0;
}
return parseInt(element.innerText.match(/[0-9,]+/)[0].replace(/,/, ''));
},
async pointReturn(dom) {
let point = 0;
const elements = dom.querySelectorAll(".swatchElement");
if (elements.length !== 0) {
for (const element of elements) {
if (!/Kindle/.test(element.innerText)) {
continue;
}
const m = element.innerText.match(/([0-9,]+)pt/);
if (!isNull(m)) {
point = parseInt(m[1].replace(/,/, ''));
break;
}
}
} else {
const element = dom.querySelector(".loyalty-points");
if (isNull(element)) {
point = 0;
} else {
point = parseInt(element.innerText.match(/[0-9,]+/)[0].replace(/,/, ''));
}
}
return isNaN(point) ? 0 : point;
},
async price(xml) {
const element = xml.querySelector("price");
if (isNull(element)) {
return null;
}
const price = parseInt(element.innerHTML
.replace(/[0-9]/g, s => String.fromCharCode(s.charCodeAt(0) - 0xfee0))
.match(/[0-9]+/)[0]);
return isNaN(price) ? null : price;
},
wishlist: {
async itemTitle(dom) {
const element = dom.querySelector('a[id^="itemName_"]');
if (isNull(element)) {
return null;
}
return element.innerText;
},
async itemAsin(dom) {
const element = dom.querySelector('.price-section');
if (isNull(element)) {
return undefined;
}
const attribute = element.getAttribute('data-item-prime-info');
if (isNull(attribute)) {
return undefined;
}
return JSON.parse(attribute).asin
},
async isKindleItem(dom) {
return /Kindle版/.test(dom.innerText);
},
async isItemProcessed(dom) {
return dom.classList.contains('SPAW_PROCESSED');
},
},
search: {
async isKindleItem(dom) {
return /Kindle版/.test(dom.innerText);
},
async title(dom) {
const title = dom.querySelector("h2 > a");
if (isNull(title)) {
return null;
}
return title.innerText
},
async asin(dom) {
return dom.getAttribute("data-asin");
},
async isBulkBuy(dom) {
return /まとめ買い/.test(dom.innerText);
}
},
bargain: {
async isKindleItem(dom) {
return /Kindle版/.test(dom.innerText);
},
async title(dom) {
const title = dom.querySelector("h2");
if (isNull(title)) {
return null;
}
return title.innerText
},
async asin(dom) {
return dom.getAttribute("data-asin");
},
async isBulkBuy(dom) {
return /まとめ買い/.test(dom.innerText);
}
}
}
const lowPriceBook = (async (isbns) => Promise.all(isbns.map(async (isbn) => {
let data;
try {
const request = await get(url.ndl(isbn));
data = {
isbn: isbn,
price: await parser.price(request.responseXML),
}
} catch (e) {
data = {
isbn: isbn,
price: null,
}
}
console.log(data);
return data;
})).then((prices) => {
return prices.reduce((a, b) => a.price < b.price ? a : b);
})
);
const itemPage = {
async itemInfo(dom) {
if (!parser.isKindlePage(dom)) {
return null;
}
const asin = await parser.asin(dom);
console.log('ASIN: ' + asin);
if (isUndefined(asin)) {
throw new Error('ASIN NOT FOUND');
}
const data = storage.load(asin);
return Promise.all([
this.bookInfo(dom, data),
this.kindleInfo(dom, data),
]).then(([bookInfo, kindleInfo]) => {
return {
asin: asin,
isbn: bookInfo.isbn,
paperPrice: bookInfo.price,
kindlePrice: kindleInfo.price,
pointReturn: kindleInfo.point,
isBought: kindleInfo.isBought,
updatedAt: Date.now(),
};
});
},
async kindleInfo(dom, data) {
return Promise.all([
parser.kindlePrice(dom),
parser.pointReturn(dom),
parser.isBought(dom),
]).then(([kindlePrice, pointReturn, isBought]) => {
const data = {
price: kindlePrice,
point: pointReturn,
isBought: isBought,
};
console.log('KINDLE INFO: ', data)
return data;
})
},
async bookInfo(dom, data) {
if (!isNull(data)) {
console.log('USE CACHE: ' + data.asin)
return {
isbn: data.isbn,
price: data.paperPrice,
}
}
const isbns = await parser.isbns(dom);
console.log('ISBN: ', isbns);
if (isbns.length === 0) {
return {
isbn: null,
price: null,
};
}
const book = await lowPriceBook(isbns);
console.log('LOW: ', book)
return {
isbn: book.isbn,
price: book.price,
}
},
async addPaperPrice(dom, paperPrice, kindlePrice) {
if (isNull(paperPrice) || paperPrice === 0 || !isNull(dom.querySelector('.print-list-price'))) {
return;
}
paperPrice = taxIncluded(paperPrice);
const off = paperPrice - kindlePrice;
const offRate = rate(off, paperPrice);
let html = '
' +
'' +
' 紙の本の価格:' +
' | ' +
'' +
' ¥' + paperPrice +
' | ' +
'
' +
'' +
'' +
' 割引:' +
' | ' +
'' +
' ¥' + off + '(' + offRate + '%)' +
' | ' +
'
' +
'' +
'' +
' ' +
' | ' +
'
';
let element = dom.querySelector('#buybox tbody');
if (isNull(element)) {
element = dom.querySelector("#buyOneClick tbody");
}
if (!isNull(element)) {
element.insertAdjacentHTML('afterbegin', html);
}
},
async addPoint(dom, price, point) {
if (!isNull(dom.querySelector('.loyalty-points')) || point === 0) {
return;
}
const pointRate = rate(point, price);
const html = '' +
'' +
' 獲得ポイント: ' +
' | ' +
'' +
' ' +
' ' +
' ' + point + 'ポイント' +
' (' + pointRate + '%)' +
' ' +
' ' +
' | ' +
'
';
let element = dom.querySelector('#buybox tbody');
if (isNull(element)) {
element = dom.querySelector("#buyOneClick tbody");
}
if (!isNull(element)) {
element.insertAdjacentHTML('beforeend', html);
}
},
async emphasisPrice(dom) {
const elements = dom.querySelectorAll("tr.kindle-price td")
const label = dom.querySelector("tr.kindle-price td")
const price = dom.querySelector("tr.kindle-price span");
if (isNull(label) || isNull(price)) {
return;
}
label.classList.remove('a-color-secondary', 'a-size-small');
label.classList.add('a-color-price', 'a-text-bold', 'a-size-medium');
price.classList.remove('a-color-secondary', 'a-size-small');
price.classList.add('a-color-price', 'a-text-bold', 'a-size-medium');
}
};
const wishlistPage = {
discoveries: [],
observer: null,
async push(nodes) {
for (const dom of Array.from(nodes).filter((element, index) => element.nodeName === "LI")) {
const title = await parser.wishlist.itemTitle(dom);
const asin = await parser.wishlist.itemAsin(dom);
if (!await parser.wishlist.isKindleItem(dom) || isUndefined(asin)) {
console.log('DROP:[' + asin + ']' + title);
continue;
}
console.log('PUSH:[' + asin + ']' + title);
this.processStart(dom);
this.discoveries.push(dom);
}
},
async initialize(dom) {
await get('https://www.amazon.co.jp/gp/product/black-curtain-redirect.html');
await this.push(dom.querySelectorAll(".g-item-sortable"));
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "childList") {
this.push(mutation.addedNodes);
}
});
});
this.observer.observe(document.querySelector("#g-items"), {
childList: true,
});
this.run();
},
async run() {
let runCount = 0;
let sleepCount = 0;
while (sleepCount < 120) {
while (this.discoveries.length > 0) {
sleepCount = 0;
if (runCount < 5) {
++runCount;
this.listItem(this.discoveries.shift()).finally(() => --runCount);
} else {
await sleep(100);
}
}
++sleepCount;
await sleep(1000);
}
console.log('OBSERVER DISCONNECT');
this.observer.disconnect();
},
async listItem(dom) {
const title = await parser.wishlist.itemTitle(dom);
const asin = await parser.wishlist.itemAsin(dom);
console.log('ITEM:[' + asin + ']' + title);
if (await parser.wishlist.isItemProcessed(dom)) {
await this.processEnd(dom);
return;
}
let data;
if (storage.isCacheActive(asin)) {
console.log('CACHE LOAD:[' + asin + ']' + title);
data = storage.load(asin);
} else {
console.log('CACHE EXPIRE:[' + asin + ']' + title);
const request = await get(url.amazon(asin));
data = await itemPage.itemInfo(domParser.parseFromString(request.response, 'text/html'));
console.log('DATA:[' + asin + ']' + title, data);
storage.save(data.asin, data);
}
await this.viewPrice(dom, data);
await this.processEnd(dom);
console.log('END:[' + asin + ']' + title);
},
async processStart(dom) {
const element = dom.querySelector('div[id^="itemInfo_"]');
if (!isNull(element)) {
element.insertAdjacentHTML('afterbegin', '処理中
');
}
},
async processEnd(dom) {
dom.querySelector('.SPAW_PROCESSING').remove();
dom.classList.add("SPAW_PROCESSED");
},
async viewPrice(dom, data) {
const paperPrice = taxIncluded(data.paperPrice);
const kindlePrice = data.kindlePrice;
const off = paperPrice - kindlePrice;
const offRate = rate(off, paperPrice)
const offRateColor = rateColor(offRate);
const point = data.pointReturn;
const pointRate = rate(point, kindlePrice)
const pointRateColor = rateColor(pointRate);
let html = '';
if (!isNull(data.paperPrice)) {
html += '
' +
'紙の本:¥' +
'' + paperPrice + '' +
'
';
}
html += '
' +
'価格:¥' +
'' + kindlePrice + '' +
'
';
if (!isNull(data.paperPrice)) {
html += '
' +
'割り引き:' +
'' + off + '円( ' + offRate + '%割引)' +
'
';
}
html += '
' +
'ポイント:' +
'' + point + 'ポイント(' + pointRate + '%還元)' +
'
';
html += '
';
dom.querySelector(".price-section").innerHTML = html;
},
}
const searchPage = {
discoveries: [],
async push(nodes) {
for (const dom of Array.from(nodes)) {
const title = await parser.search.title(dom);
const asin = await parser.search.asin(dom);
if (!await parser.search.isKindleItem(dom) || isUndefined(asin) || await parser.search.isBulkBuy(dom)) {
console.log('DROP:[' + asin + ']' + title);
continue;
}
console.log('PUSH:[' + asin + ']' + title);
this.processStart(dom);
this.discoveries.push(dom);
}
},
async initialize(dom) {
await get('https://www.amazon.co.jp/gp/product/black-curtain-redirect.html');
await this.push(dom.querySelectorAll("[data-asin]"));
this.run();
},
async processStart(dom) {
const element = dom.querySelector("h2 > a");
if (!isNull(element)) {
element.insertAdjacentHTML('beforebegin', '処理中
');
}
},
async processEnd(dom) {
dom.querySelector('.SPAW_PROCESSING').remove();
dom.classList.add("SPAW_PROCESSED");
},
async run() {
let runCount = 0;
while (this.discoveries.length > 0) {
if (runCount < 5) {
++runCount;
this.item(this.discoveries.shift()).finally(() => --runCount);
} else {
await sleep(100);
}
}
},
async item(dom) {
const title = await parser.search.title(dom);
const asin = await parser.search.asin(dom);
console.log('ITEM:[' + asin + ']' + title);
let data;
if (this.isCacheActive(asin)) {
console.log('CACHE LOAD:[' + asin + ']' + title);
data = storage.load(asin);
} else {
console.log('CACHE EXPIRE:[' + asin + ']' + title);
const request = await get(url.amazon(asin));
data = await itemPage.itemInfo(domParser.parseFromString(request.response, 'text/html'));
console.log('DATA:[' + asin + ']' + title, data);
if (!isNull(data.asin)) {
storage.save(data.asin, data);
}
}
await this.viewPrice(dom, data);
await this.processEnd(dom);
console.log('END:[' + asin + ']' + title);
},
isCacheActive(asin) {
if (storage.exists(asin)) {
const data = storage.load(asin);
if (data.isBought) {
return true;
}
}
return storage.isCacheActive(asin);
},
async viewPrice(dom, data) {
const paperPrice = taxIncluded(data.paperPrice);
const kindlePrice = data.kindlePrice;
const off = paperPrice - kindlePrice;
const offRate = rate(off, paperPrice)
const offRateColor = rateColor(offRate);
const point = data.pointReturn;
const pointRate = rate(point, kindlePrice)
const pointRateColor = rateColor(pointRate);
let html = '';
if (data.isBought) {
html += '
購入済
';
html += '
' +
'価格:¥' +
'' + kindlePrice + '' +
'
';
const buyButton = dom.querySelector(".a-spacing-top-mini");
if (!isNull(buyButton)) {
buyButton.remove();
}
} else {
if (!isNull(data.paperPrice)) {
html += '
' +
'紙の本:¥' +
'' + paperPrice + '' +
'
';
} else if (isNull(data.isbn)) {
html += '
ISBN不明
';
}
html += '
' +
'価格:¥' +
'' + kindlePrice + '' +
'
';
if (!isNull(data.paperPrice)) {
html += '
' +
'割り引き:' +
'' + off + '円( ' + offRate + '%割引)' +
'
';
}
html += '
' +
'ポイント:' +
'' + point + 'ポイント(' + pointRate + '%還元)' +
'
';
}
html += '
';
dom.querySelectorAll("div.a-row.a-size-base").forEach(element => {
if (/ポイント/.test(element.innerText) || /税込/.test(element.innerText) || /購入/.test(element.innerText)) {
element.remove();
} else if (/[¥\\]/.test(element.innerText)) {
element.innerHTML = html;
}
});
},
}
const bargainPage = {
discoveries: [],
async push(nodes) {
for (const dom of Array.from(nodes)) {
const title = await parser.bargain.title(dom);
const asin = await parser.bargain.asin(dom);
if (!await parser.search.isKindleItem(dom) || isUndefined(asin) || await parser.search.isBulkBuy(dom)) {
console.log('DROP:[' + asin + ']' + title);
continue;
}
console.log('PUSH:[' + asin + ']' + title);
this.processStart(dom);
this.discoveries.push(dom);
}
},
async initialize(dom) {
await get('https://www.amazon.co.jp/gp/product/black-curtain-redirect.html');
await this.push(dom.querySelectorAll(".s-result-list [data-asin]"));
this.run();
},
async processStart(dom) {
const element = dom.querySelector("h2");
if (!isNull(element)) {
element.insertAdjacentHTML('beforebegin', '処理中
');
}
},
async processEnd(dom) {
dom.querySelector('.SPAW_PROCESSING').remove();
dom.classList.add("SPAW_PROCESSED");
},
async run() {
let runCount = 0;
while (this.discoveries.length > 0) {
if (runCount < 5) {
++runCount;
this.item(this.discoveries.shift()).finally(() => --runCount);
} else {
await sleep(100);
}
}
},
async item(dom) {
const title = await parser.bargain.title(dom);
const asin = await parser.bargain.asin(dom);
console.log('ITEM:[' + asin + ']' + title);
let data;
if (this.isCacheActive(asin)) {
console.log('CACHE LOAD:[' + asin + ']' + title);
data = storage.load(asin);
} else {
console.log('CACHE EXPIRE:[' + asin + ']' + title);
const request = await get(url.amazon(asin));
data = await itemPage.itemInfo(domParser.parseFromString(request.response, 'text/html'));
console.log('DATA:[' + asin + ']' + title, data);
if (!isNull(data.asin)) {
storage.save(data.asin, data);
}
}
await this.viewPrice(dom, data);
await this.processEnd(dom);
console.log('END:[' + asin + ']' + title);
},
isCacheActive(asin) {
if (storage.exists(asin)) {
const data = storage.load(asin);
if (data.isBought) {
return true;
}
}
return storage.isCacheActive(asin);
},
async viewPrice(dom, data) {
const paperPrice = taxIncluded(data.paperPrice);
const kindlePrice = data.kindlePrice;
const off = paperPrice - kindlePrice;
const offRate = rate(off, paperPrice)
const offRateColor = rateColor(offRate);
const point = data.pointReturn;
const pointRate = rate(point, kindlePrice)
const pointRateColor = rateColor(pointRate);
let html = '';
if (data.isBought) {
html += '
購入済
';
html += '
' +
'価格:¥' +
'' + kindlePrice + '' +
'
';
const buyButton = dom.querySelector(".a-spacing-top-mini");
if (!isNull(buyButton)) {
buyButton.remove();
}
} else {
if (!isNull(data.paperPrice)) {
html += '
' +
'紙の本:¥' +
'' + paperPrice + '' +
'
';
} else if (isNull(data.isbn)) {
html += '
ISBN不明
';
}
html += '
' +
'価格:¥' +
'' + kindlePrice + '' +
'
';
if (!isNull(data.paperPrice)) {
html += '
' +
'割り引き:' +
'' + off + '円( ' + offRate + '%割引)' +
'
';
}
html += '
' +
'ポイント:' +
'' + point + 'ポイント(' + pointRate + '%還元)' +
'
';
}
html += '
';
dom.querySelectorAll(".a-column > .a-row").forEach(element => {
console.log(element);
if (/ポイント/.test(element.innerText) || /税込/.test(element.innerText) || /Kindle Unlimited/.test(element.innerText) || /Kindle 価格/.test(element.innerText)) {
element.remove();
} else if (/[¥\\]/.test(element.innerText)) {
element.innerHTML = html;
}
});
},
}
const main = (async () => {
const url = location.href;
const dom = document
storageClean();
if (/\/(dp|gp)\//.test(url) && await parser.isKindlePage(dom)) {
console.log('ITEM PAGE');
itemPage.emphasisPrice(dom);
await itemPage.itemInfo(dom).then((data) => {
storage.save(data.asin, data);
itemPage.addPaperPrice(dom, data.paperPrice, data.kindlePrice);
itemPage.addPoint(dom, data.kindlePrice, data.pointReturn);
});
} else if (/\/wishlist\//.test(url)) {
console.log('WISHLIST PAGE');
await wishlistPage.initialize(dom);
} else if (/\/s[?\/]/.test(url)) {
console.log('SEARCH PAGE');
await searchPage.initialize(dom);
} else if (/\/b[?\/]/.test(url)) {
console.log('BARGAIN PAGE');
await bargainPage.initialize(dom);
}
});
main();
})();