Greasy Fork is available in English.

Show points on Amazon.co.jp wishlist

Amazon.co.jpの欲しいものリストと検索ページで、Kindleの商品にポイントを表示しようとします

// ==UserScript==
// @name         Show points on Amazon.co.jp wishlist
// @version      23.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*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_xmlhttpRequest
// @compatible   Chrome
// @license      Apache-2.0
// ==/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) {
            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 = '<tr class="print-list-price">' +
                '<td class="a-span1 a-color-secondary a-size-small a-text-left a-nowrap">' +
                '    紙の本の価格:' +
                '</td>' +
                '<td class="a-color-base a-align-bottom a-text-strike">' +
                '    ¥' + paperPrice +
                '</td>' +
                '</tr>' +
                '<tr class="savings">' +
                '<td class="a-span1 a-color-secondary a-text-left a-nowrap">' +
                '    割引:' +
                '</td>' +
                '<td class="a-color-base a-align-bottom">' +
                '    ¥' + off + '(' + offRate + '%)' +
                '</td>' +
                '</tr>' +
                '<tr>' +
                '<td colspan="2" class="a-span1 a-color-secondary">' +
                '    <hr class="a-spacing-small a-spacing-top-small a-divider-normal">' +
                '</td>' +
                '</tr>';

            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 = '<tr class="loyalty-points">' +
                '<td class="a-span6 a-color-secondary a-size-base a-text-left">' +
                '  <div class="a-section a-spacing-top-small">獲得ポイント:</div>' +
                '</td>' +
                '<td class="a-align-bottom">' +
                '  <div class="a-section a-spacing-top-small">' +
                '    <span>' +
                '      <span class="a-size-base a-color-price a-text-bold">' + point + 'ポイント</span>' +
                '        <span class="a-size-base a-color-price">(' + pointRate + '%)</span>' +
                '      </span>' +
                '    </div>' +
                '  </td>' +
                '</tr>';

            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', '<div class="a-row SPAW_PROCESSING" style="color:#EE0077">取得待ち</div>');
            }
        },

        async processEnd(dom, message) {
            if (message) {
                dom.querySelector('.SPAW_PROCESSING').innerHTML = '<div class="a-row SPAW_PROCESSING" style="color:red">' + message + '</div>';
            } 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 = '<div>';
            if (!isNull(data.paperPrice)) {
                html += '<div>' +
                    '<span class="a-price-symbol">紙の本:¥</span>' +
                    '<span class="a-price-whole">' + paperPrice + '</span>' +
                    '</div>';
            } else if (isNull(data.isbn)) {
                html += '<span class="a-price-symbol" style="color:#ff3c00">紙の本:無し</span>';
            }
            html += '<div>' +
                '<span class="a-price-symbol a-color-price a-size-large">価格:¥</span>' +
                '<span class="a-price-whole a-color-price a-size-large">' + kindlePrice + '</span>' +
                '</div>';
            if (!isNull(data.paperPrice)) {
                html += '<div style="color:' + offRateColor.color + ';background-color:' + offRateColor.bgColor + '">' +
                    '<span class="a-price-symbol">割り引き:</span>' +
                    '<span class="a-price-whole">' + off + '円( ' + Math.round(offRate) + '%割引)</span>' +
                    '</div>';
            }

            html += '<div style="color:' + pointRateColor.color + ';background-color:' + pointRateColor.bgColor + '">' +
                '<span class="a-price-symbol">ポイント:</span>' +
                '<span class="a-price-whole">' + point + 'ポイント(' + Math.round(pointRate) + '%還元)</span>' +
                '</div>';

            if (data?.isKindleUnlimited) {
                html += '<div>' +
                    '<span class="a-price-symbol a-text-bold">Kindle Unlimited対象</span>' +
                    '</div>';
            }

            if (data?.campaigns) {
                for (const campaign of data.campaigns) {
                    html += '<div>' +
                        '<span class="a-price-symbol a-text-bold">' + campaign + '</span>' +
                        '</div>';
                }
            }

            html += '</div>';

            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', '<div class="a-row SPAW_PROCESSING" style="color:#EE0077">取得待ち</div>');
            }
        },

        async processEnd(dom, message) {
            if (message) {
                dom.querySelector('.SPAW_PROCESSING').innerHTML = '<div class="a-row SPAW_PROCESSING" style="color:red">' + message + '</div>';
            } 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 = '<div>';
            if (data.isBought) {
                html += '<i class="a-icon a-icon-success" role="presentation"></i><span class="a-size-medium a-color-success"> 購入済み </span>';
                html += '<div>' +
                    '<span class="a-size-base a-color-secondary">価格:¥</span>' +
                    '<span class="a-size-base a-color-secondary">' + kindlePrice + '</span>' +
                    '</div>';
                const buyButton = dom.querySelector(".a-spacing-top-mini");
                if (!isNull(buyButton)) {
                    buyButton.remove();
                }
            } else {
                if (!isNull(data.paperPrice)) {
                    html += '<div>' +
                        '<span class="a-price-symbol">紙の本:¥</span>' +
                        '<span class="a-price-whole">' + paperPrice + '</span>' +
                        '</div>';
                } else if (data.isKdp) {
                    html += '<div class="a-size-medium" style="color:#FFFFFF;background-color:#ff0000">KDP</div>';
                } else if (isNull(data.isbn)) {
                    html += '<div class="a-size-medium" style="color:#ff3c00">ISBN不明</div>';
                }
                html += '<div>' +
                    '<span class="a-price-symbol a-color-price a-size-large">価格:¥</span>' +
                    '<span class="a-price-whole a-color-price a-size-large">' + kindlePrice + '</span>' +
                    '</div>';
                if (!isNull(data.paperPrice)) {
                    html += '<div style="color:' + offRateColor.color + ';background-color:' + offRateColor.bgColor + '">' +
                        '<span class="a-price-symbol">割り引き:</span>' +
                        '<span class="a-price-whole">' + off + '円( ' + Math.round(offRate) + '%割引)</span>' +
                        '</div>';
                }
                html += '<div style="color:' + pointRateColor.color + ';background-color:' + pointRateColor.bgColor + '">' +
                    '<span class="a-price-symbol">ポイント:</span>' +
                    '<span class="a-price-whole">' + point + 'ポイント(' + Math.round(pointRate) + '%還元)</span>' +
                    '</div>';

                if (data?.campaigns) {
                    for (const campaign of data.campaigns) {
                        html += '<div>' +
                            '<span class="a-price-symbol a-text-bold">' + campaign + '</span>' +
                            '</div>';
                    }
                }
            }
            html += '</div>';

            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();
})();