// ==UserScript== // @name Show points on Amazon.co.jp wishlist // @version 25.5.1 // @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* // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_xmlhttpRequest // @compatible firefox // @license Apache-2.0 // @downloadURL https://update.greasyfork.icu/scripts/37063/Show%20points%20on%20Amazoncojp%20wishlist.user.js // @updateURL https://update.greasyfork.icu/scripts/37063/Show%20points%20on%20Amazoncojp%20wishlist.meta.js // ==/UserScript== /********************************************************************************************************************** NOTICE:このアプリケーションは国立国会図書館サーチAPI( https://iss.ndl.go.jp/information/api/ )と、openBDAPI( https://openbd.jp/ )を利用しています **********************************************************************************************************************/ (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 PROCESSES = 3; const COMMERCIAL_PUBLISHERS = [ '集英社', '講談社', 'KADOKAWA', '小学館', '日経BP', '東京書籍', '学研プラス', '文藝春秋', 'SBクリエイティブ', 'インプレス', 'DeNA', 'スクウェア・エニックス', 'ダイヤモンド社', 'ドワンゴ', '一迅社', '技術評論社', '近代科学社', '幻冬舎', '秋田書店', '少年画報社', '新潮社', '双葉社', '早川書房', '竹書房', '筑摩書房', '朝日新聞出版', '東洋経済新報社', '徳間書店', '日本文芸社', '白泉社', '扶桑社', '芳文社', '翔泳社', ]; const taxIncluded = listPrice => Math.floor(listPrice * (1 + TAX)); const isNull = value => value === null; const isUndefined = value => value === undefined; const hasValue = value => !isNull(value) && !isUndefined(value); const rate = ((numerator, denominator) => denominator === 0 ? 0 : 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 < 0) { return { color: '#9B1D1E', bgColor: 'initial', }; } else 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; }, ndlPublisher(publisher) { return 'https://iss.ndl.go.jp/api/sru?operation=searchRetrieve&recordSchema=dcndl&recordPacking=xml&maximumRecords=1&mediatype=1&query=publisher=' + publisher; }, amazon(asin) { return 'https://www.amazon.co.jp/dp/' + asin; }, openbd(isbn) { return 'https://api.openbd.jp/v1/get?isbn=' + isbn; }, } const storage = { async save(key, data) { console.log('SAVE: ' + key); if (!hasValue(key) || !hasValue(data)) { return null; } GM_setValue(key, JSON.stringify(data)); console.log('SAVED: ' + key, data); }, load(key) { console.log('LOAD: ' + key); if (!hasValue(key)) { return null; } const data = GM_getValue(key); console.log('LOADED: ' + key, data); if (!hasValue(data)) { return null; } return JSON.parse(data); }, exists(key) { return hasValue(GM_getValue(key)); }, async delete(key) { console.log('DELETE: ' + key); GM_deleteValue(key); }, list() { return GM_listValues(); }, clean() { const keys = this.list(); const now = Date.now(); for (const key of keys) { if (key === 'SETTINGS' || key === 'PUBLISHERS') { continue; } 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 isAgeVerification(dom) { const element = dom.querySelector('#black-curtain-warning'); return !isNull(element); }, async isKindleUnlimited(dom) { const element = dom.querySelector('#tmm-ku-upsell'); return !isNull(element); }, async isbns(dom) { let isbns = []; const elements = dom.querySelectorAll('#tmmSwatches 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 Array.from(new Set(isbns)); }, async isBought(dom) { const element = dom.querySelector('#booksInstantOrderUpdate_feature_div'); if (isNull(element)) { return false; } return /購入/.test(element.innerText); }, async isKdp(dom) { const elements = dom.querySelectorAll("#detailBullets_feature_div .a-list-item"); for (const element of elements) { if (/出版社/.test(element.innerText)) { const m = element.querySelector('span:nth-child(2)').innerText.match(/^[^;(]*/); if (isNull(m) && hasValue(m[0])) { return true; } const publisher = m[0].trim(); console.log('publisher:' + publisher); const findIndex = COMMERCIAL_PUBLISHERS.findIndex(item => new RegExp(item).test(publisher)); if (findIndex !== -1) { return false; } let publishers = storage.load('PUBLISHERS'); if (isNull(publishers)) { publishers = {}; } else if (!isUndefined(publishers[publisher])) { return !publishers[publisher]; } const res = await get(url.ndlPublisher(publisher)); const hasPublisher = await parser.hasPublisher(res.responseXML); publishers[publisher] = hasPublisher; await storage.save('PUBLISHERS', publishers); return !hasPublisher; } } return true; }, async asin(dom) { const element = dom.querySelector("#ASIN"); if (isNull(element)) { return null; } return element.value; }, async kindlePrice(dom) { let element = dom.querySelector("#kindle-price"); if (!isNull(element)) { return parseInt(element.innerText.match(/[0-9,]+/)[0].replace(/,/, '')); } element = dom.querySelector("span.extra-message.olp-link"); if (!isNull(element)) { return parseInt(element.innerText.match(/[0-9,]+/)[0].replace(/,/, '')); } return null; }, 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; }, async hasPublisher(xml) { const element = xml.querySelector("numberOfRecords"); if (isNull(element)) { return null; } return element.innerHTML !== '0'; }, async campaigns(dom) { return []; const elements = dom.querySelectorAll('span > div.a-section.a-spacing-none > div'); let tmp = []; for (const element of elements) { const spanTags = element.getElementsByTagName('span'); tmp.push(spanTags[0].innerText); } return tmp; }, 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) { const elements = dom.querySelectorAll('a.a-text-bold'); if (elements.length !== 0) { for (const element of elements) { if (/^Kindle版/.test(element.innerHTML.trim())) { return true; } } } return false; }, async title(dom) { const title = dom.querySelector("h2 > a"); if (isNull(title)) { return null; } return title.innerText.trim(); }, 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) => { try { let price = await getOpenBdPrice(isbn); if (hasValue(price)) { return { isbn: isbn, price: price, }; } price = await getNdlPrice(url.ndl(isbn)); return { isbn: isbn, price: price, } } catch (e) { return { isbn: isbn, price: null, } } })).then((prices) => { return prices.reduce((a, b) => a.price < b.price ? a : b); }) ); const getNdlPrice = (async (isbn) => { const res = await get(url.ndl(isbn)); return await parser.price(res.responseXML); }); const getOpenBdPrice = (async (isbn) => { const res = await get(url.openbd(isbn)); const json = JSON.parse(res.responseText); try { return json[0]['onix']['ProductSupply']['SupplyDetail']['Price'][0]['PriceAmount']; } catch (e) { return null; } }); const itemPage = { async itemInfo(dom) { if (await parser.isAgeVerification(dom)) { throw new Error('年齢確認が必要です'); } if (!await parser.isKindlePage(dom)) { return null; } const asin = await parser.asin(dom); if (!hasValue(asin)) { throw new Error('ASINが見つかりません'); } 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, isKdp: kindleInfo.isKdp, isKindleUnlimited: kindleInfo.isKindleUnlimited, campaigns: kindleInfo.campaigns, updatedAt: Date.now(), }; }); }, async kindleInfo(dom, data) { return Promise.all([ data, parser.kindlePrice(dom), parser.pointReturn(dom), parser.isBought(dom), parser.isKdp(dom), parser.isKindleUnlimited(dom), parser.campaigns(dom) ]).then(([data, kindlePrice, pointReturn, isBought, isKdp, isKindleUnlimited, campaigns]) => { const info = { price: isNull(kindlePrice) ? data.kindlePrice : kindlePrice, point: pointReturn, isBought: isBought, isKdp: isKdp, isKindleUnlimited: isKindleUnlimited, campaigns: campaigns, }; console.log('KINDLE INFO: ', info) return info; }); }, async bookInfo(dom, data) { if (hasValue(data) && hasValue(data.paperPrice)) { 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, } }, clickPrompt(dom) { let prompt = dom.querySelector('#buyOneClick .a-expander-prompt'); if (!isNull(prompt)) { prompt.click(); } }, async addPaperPrice(dom, paperPrice, kindlePrice) { if (isNull(paperPrice) || paperPrice === 0) { return; } paperPrice = taxIncluded(paperPrice); const off = paperPrice - kindlePrice; const offRate = Math.round(rate(off, paperPrice)); let html = '' + '' + ' 紙の本の価格:' + '' + '' + ' ¥' + paperPrice + '' + '' + '' + '' + ' 割引:' + '' + '' + ' ¥' + off + '(' + offRate + '%)' + '' + '' + '' + '' + '
' + '' + ''; let element = dom.querySelector('#buybox tbody'); let childNode = dom.querySelector('.print-list-price'); if (!isNull(childNode)) { childNode.parentNode.removeChild(childNode); } 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 = Math.round(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); await this.processStart(dom); this.discoveries.push(dom); } }, async initialize(dom) { 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, }); await this.run(); }, async run() { let runCount = 0; for (; ;) { while (this.discoveries.length > 0) { if (runCount < PROCESSES) { ++runCount; this.listItem(this.discoveries.shift()).finally(() => --runCount); } else { await sleep(200); } } await sleep(1000); } }, async listItem(dom) { dom.querySelector('.SPAW_PROCESSING').textContent = '取得中'; 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 res = await get(url.amazon(asin)); try { data = await itemPage.itemInfo(domParser.parseFromString(res.response, 'text/html')); } catch (e) { await this.processEnd(dom, e.message); return; } } if (isNull(data)) { await this.processEnd(dom); return; } await 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, message) { if (message) { dom.querySelector('.SPAW_PROCESSING').innerHTML = '
' + message + '
'; } else { 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 + '' + '
'; } else if (isNull(data.isbn)) { html += '紙の本:無し'; } html += '
' + '価格:¥' + '' + kindlePrice + '' + '
'; if (!isNull(data.paperPrice)) { html += '
' + '割り引き:' + '' + off + '円( ' + Math.round(offRate) + '%割引)' + '
'; } html += '
' + 'ポイント:' + '' + point + 'ポイント(' + Math.round(pointRate) + '%還元)' + '
'; if (data?.isKindleUnlimited) { html += '
' + 'Kindle Unlimited対象' + '
'; } if (data?.campaigns) { for (const campaign of data.campaigns) { html += '
' + '' + campaign + '' + '
'; } } html += '
'; dom.querySelector(".price-section").innerHTML = html; }, } const searchPage = { discoveries: [], observer: null, 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 this.push(dom.querySelectorAll("[data-asin]")); this.observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === "childList") { this.push(mutation.addedNodes); } }); }); this.observer.observe(document.querySelector('div.s-search-results'), { childList: true, }); await this.run(); }, async processStart(dom) { const element = dom.querySelector("h2 > a"); if (!isNull(element)) { element.insertAdjacentHTML('afterbegin', '
取得待ち
'); } }, async processEnd(dom, message) { if (message) { dom.querySelector('.SPAW_PROCESSING').innerHTML = '
' + message + '
'; } else { dom.querySelector('.SPAW_PROCESSING').remove(); dom.classList.add("SPAW_PROCESSED"); } }, async run() { let runCount = 0; for (; ;) { while (this.discoveries.length > 0) { if (runCount < PROCESSES) { ++runCount; this.item(this.discoveries.shift()).finally(() => --runCount); } else { await sleep(200); } } await sleep(1000); } }, async item(dom) { dom.querySelector('.SPAW_PROCESSING').textContent = '取得中'; 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 res = await get(url.amazon(asin)); try { data = await itemPage.itemInfo(domParser.parseFromString(res.response, 'text/html')); } catch (e) { await this.processEnd(dom, e.message); return; } } if (isNull(data)) { await this.processEnd(dom); return; } await storage.save(data.asin, data); await this.viewPrice(dom, data); await this.processEnd(dom); console.log('END:[' + asin + ']' + title); }, isCacheActive(asin) { 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 (data.isKdp) { html += '
KDP
'; } else if (isNull(data.isbn)) { html += '
ISBN不明
'; } html += '
' + '価格:¥' + '' + kindlePrice + '' + '
'; if (!isNull(data.paperPrice)) { html += '
' + '割り引き:' + '' + off + '円( ' + Math.round(offRate) + '%割引)' + '
'; } html += '
' + 'ポイント:' + '' + point + 'ポイント(' + Math.round(pointRate) + '%還元)' + '
'; if (data?.campaigns) { for (const campaign of data.campaigns) { html += '
' + '' + campaign + '' + '
'; } } } html += '
'; let isChanged = false; 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)) { if (!isChanged) { element.innerHTML = html; isChanged = true; } } }); }, } const main = (async () => { const url = location.href; const dom = document storageClean(); if (/\/(dp|gp)\//.test(url) && await parser.isKindlePage(dom)) { console.log('ITEM PAGE'); await itemPage.emphasisPrice(dom); itemPage.clickPrompt(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); } }); main(); })();