您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
Amazon.co.jpの欲しいものリストと検索ページで、Kindleの商品にポイントを表示しようとします
// ==UserScript== // @name Show points on Amazon.co.jp wishlist // @version 25.9.0 // @description Amazon.co.jpの欲しいものリストと検索ページで、Kindleの商品にポイントを表示しようとします // @namespace http://greasyfork.icu/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 // ==/UserScript== /********************************************************************************************************************** NOTICE:このアプリケーションは国立国会図書館サーチAPI( https://iss.ndl.go.jp/information/api/ )と、openBDAPI( https://openbd.jp/ )を利用しています **********************************************************************************************************************/ (function () { "use strict"; // =========================================================================================== // 設定・定数 // =========================================================================================== const Config = { CACHE_LIFETIME_DAYS: 14, RESCAN_INTERVAL_HOURS: 3, AUTO_CLEAN_PROBABILITY: 0.01, CONCURRENT_PROCESSES: 3, PROCESS_INTERVAL_MS: 200, DISCOVERY_INTERVAL_MS: 1000, TAX_RATE: 0.1, DEBUG_MODE: false, ISBN: { CHECK_10_MULTIPLIER: [10, 9, 8, 7, 6, 5, 4, 3, 2], CHECK_13_MULTIPLIER: [1, 3], MODULO_10: 10, MODULO_11: 11, }, COLORS: { NEGATIVE: { color: "#9B1D1E", bgColor: "initial" }, LOW: { color: "initial", bgColor: "initial" }, MEDIUM: { color: "initial", bgColor: "#F7D44A" }, HIGH: { color: "#FFFFFF", bgColor: "#FE7E03" }, VERY_HIGH: { color: "#FFFFFF", bgColor: "#9B1D1E" }, ERROR: "#ff3c00", KDP: { color: "#FFFFFF", bgColor: "#ff0000" }, PROCESSING: "#EE0077", }, CLASSES: { PROCESSING: "amz-point-processing", PROCESSED: "amz-point-processed", }, }; const CACHE_LIFETIME_MS = Config.CACHE_LIFETIME_DAYS * 24 * 60 * 60 * 1000; const RESCAN_INTERVAL_MS = Config.RESCAN_INTERVAL_HOURS * 60 * 60 * 1000; // =========================================================================================== // URL管理 // =========================================================================================== const API_URLS = { NDL_ISBN: (isbn) => `https://iss.ndl.go.jp/api/sru?operation=searchRetrieve&recordSchema=dcndl&recordPacking=xml&query=isbn=${isbn}`, NDL_PUBLISHER: (publisher) => `https://iss.ndl.go.jp/api/sru?operation=searchRetrieve&recordSchema=dcndl&recordPacking=xml&maximumRecords=1&mediatype=1&query=publisher=${publisher}`, AMAZON_PRODUCT: (asin) => `https://www.amazon.co.jp/dp/${asin}`, OPENBD: (isbn) => `https://api.openbd.jp/v1/get?isbn=${isbn}`, }; // =========================================================================================== // 商業出版社リスト // =========================================================================================== const COMMERCIAL_PUBLISHERS = [ "集英社", "講談社", "KADOKAWA", "小学館", "日経BP", "東京書籍", "学研プラス", "文藝春秋", "SBクリエイティブ", "インプレス", "DeNA", "スクウェア・エニックス", "ダイヤモンド社", "ドワンゴ", "一迅社", "技術評論社", "近代科学社", "幻冬舎", "秋田書店", "少年画報社", "新潮社", "双葉社", "早川書房", "竹書房", "筑摩書房", "朝日新聞出版", "東洋経済新報社", "徳間書店", "日本文芸社", "白泉社", "扶桑社", "芳文社", "翔泳社", ]; const Logger = { log: (...args) => Config.DEBUG_MODE && console.log(...args), error: (...args) => console.error(...args), info: (...args) => console.info(...args), }; const Utils = { calculateTaxIncludedPrice: (price) => Math.floor(price * (1 + Config.TAX_RATE)), calculateRate: (numerator, denominator) => denominator === 0 ? 0 : (numerator / denominator) * 100, sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), getColorByRate(rate) { const { COLORS } = Config; if (rate < 0) return COLORS.NEGATIVE; if (rate < 20) return COLORS.LOW; if (rate < 50) return COLORS.MEDIUM; if (rate < 80) return COLORS.HIGH; return COLORS.VERY_HIGH; }, parseIntSafe(str, removeComma = false) { if (!str) return null; const cleaned = removeComma ? str.replace(/,/g, "") : str; const parsed = parseInt(cleaned, 10); return isNaN(parsed) ? null : parsed; }, extractNumber(text, pattern, index = 0, removeComma = false) { if (!text) return null; const match = text.match(pattern); if (!match || !match[index]) return null; return this.parseIntSafe(match[index], removeComma); }, validateISBN(isbn) { const { ISBN } = Config; // ISBN-10のチェック if (/^4[0-9]{8}[0-9X]?$/.test(isbn)) { let checksum = 0; for (let i = 0; i < 9; i++) { checksum += ISBN.CHECK_10_MULTIPLIER[i] * Number(isbn[i]); } checksum = (ISBN.MODULO_11 - (checksum % ISBN.MODULO_11)) % ISBN.MODULO_11; checksum = checksum === 10 ? "X" : String(checksum); return checksum === isbn[9]; } // ISBN-13のチェック if (/^9784[0-9]{9}?$/.test(isbn)) { let checksum = 0; for (let i = 0; i < 12; i++) { checksum += Number(isbn[i]) * ISBN.CHECK_13_MULTIPLIER[i % 2]; } checksum = (ISBN.MODULO_10 - (checksum % ISBN.MODULO_10)) % ISBN.MODULO_10; return String(checksum) === isbn[12]; } return false; }, }; class StorageManager { constructor() { this.SETTINGS_KEY = "SETTINGS"; this.PUBLISHERS_KEY = "PUBLISHERS"; } save(key, data) { if (!key || !data) return null; GM_setValue(key, JSON.stringify(data)); Logger.log(`SAVED: ${key}`, data); } load(key) { if (!key) return null; const data = GM_getValue(key); Logger.log(`LOADED: ${key}`, data); return data ? JSON.parse(data) : null; } exists(key) { return !!GM_getValue(key); } delete(key) { Logger.log(`DELETE: ${key}`); GM_deleteValue(key); } list() { return GM_listValues(); } isCacheActive(asin) { if (!this.exists(asin)) return false; const data = this.load(asin); return data && Date.now() - data.updatedAt <= RESCAN_INTERVAL_MS; } clean() { const keys = this.list(); const now = Date.now(); keys.forEach((key) => { if (key === this.SETTINGS_KEY || key === this.PUBLISHERS_KEY) return; const data = this.load(key); if (data && now - data.updatedAt > CACHE_LIFETIME_MS) { this.delete(key); } }); } autoClean() { if (Math.random() < Config.AUTO_CLEAN_PROBABILITY) { this.clean(); } } } class HttpClient { static async get(url) { Logger.log(`GET: ${url}`); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, withCredentials: true, onload: resolve, onerror: reject, onabort: reject, ontimeout: reject, }); }); } } class APIClient { static async fetchNDLPrice(isbn) { try { const response = await HttpClient.get(API_URLS.NDL_ISBN(isbn)); const priceElement = response.responseXML?.querySelector("price"); if (!priceElement) return null; const priceText = priceElement.innerHTML.replace(/[0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 0xfee0), ); return Utils.extractNumber(priceText, /[0-9]+/); } catch (error) { Logger.error("NDL API Error:", error); return null; } } static async fetchOpenBDPrice(isbn) { try { const response = await HttpClient.get(API_URLS.OPENBD(isbn)); const json = JSON.parse(response.responseText); return ( json?.[0]?.onix?.ProductSupply?.SupplyDetail?.Price?.[0] ?.PriceAmount ?? null ); } catch (error) { Logger.error("OpenBD API Error:", error); return null; } } static async checkPublisherInNDL(publisher) { try { const response = await HttpClient.get( API_URLS.NDL_PUBLISHER(publisher), ); const recordsElement = response.responseXML?.querySelector("numberOfRecords"); return recordsElement?.innerHTML !== "0"; } catch (error) { Logger.error("NDL Publisher Check Error:", error); return false; } } /** * 複数のISBNから最も安い価格の書籍情報を取得 * @param {string[]} isbns - ISBNの配列 * @returns {Promise<{isbn: string|null, price: number|null, source: string|null}>} */ static async findLowestPriceBook(isbns) { if (!isbns || isbns.length === 0) { return { isbn: null, price: null, source: null }; } const priceResults = await this.fetchAllBookPrices(isbns); const validPrices = priceResults.filter(result => result.price != null && result.price > 0 ); if (validPrices.length === 0) { Logger.log("No valid prices found for ISBNs:", isbns); return { isbn: null, price: null, source: null }; } return validPrices.reduce((cheapest, current) => current.price < cheapest.price ? current : cheapest ); } /** * 複数のISBNから価格情報を並列取得 * @private */ static async fetchAllBookPrices(isbns) { const pricePromises = isbns.map(isbn => this.fetchBookPriceWithSource(isbn) ); try { return await Promise.all(pricePromises); } catch (error) { Logger.error("Error fetching book prices:", error); return []; } } /** * 単一ISBNの価格を複数のソースから取得 * @private */ static async fetchBookPriceWithSource(isbn) { // OpenBDを優先的に試す(一般的に高速) const openBDPrice = await this.fetchOpenBDPrice(isbn); if (openBDPrice != null) { return { isbn, price: openBDPrice, source: 'OpenBD' }; } // OpenBDで見つからない場合はNDLを試す const ndlPrice = await this.fetchNDLPrice(isbn); if (ndlPrice != null) { return { isbn, price: ndlPrice, source: 'NDL' }; } // どちらでも見つからない場合 return { isbn, price: null, source: null }; } } class AmazonDOMParser { constructor() { this.parser = new window.DOMParser(); } parseHTML(html) { return this.parser.parseFromString(html, "text/html"); } querySelector(dom, selector) { return dom.querySelector(selector); } querySelectorAll(dom, selector) { return Array.from(dom.querySelectorAll(selector)); } isKindlePage(dom) { const element = this.querySelector(dom, "#title"); return element && /kindle版/i.test(element.innerText); } isAgeVerification(dom) { return !!this.querySelector(dom, "#black-curtain-warning"); } isKindleUnlimited(dom) { Logger.log(this.querySelector(dom, "#Kibbo-KINDLE_UNLIMITED-Desktop"),!!this.querySelector(dom, "#Kibbo-KINDLE_UNLIMITED-Desktop")) return !!this.querySelector(dom, "#Kibbo-KINDLE_UNLIMITED-Desktop"); } getASIN(dom) { return this.querySelector(dom, "#ASIN")?.value ?? null; } getKindlePrice(dom) { const element = this.querySelector(dom, "#Ebooks-desktop-KINDLE_ALC-prices-kindlePrice"); if (!element) return null; Logger.log(element); return Utils.extractNumber(element.innerText, /[0-9,]+/, 0, true); } getPointReturn(dom) { // まとめ買いキャンペーンポイント const multibuyCPElement = this.querySelector( dom, '[id^="added-slot-"] .a-size-base', ); if (multibuyCPElement) { const points = Utils.extractNumber(multibuyCPElement.innerText, /([0-9,]+)pt/, 1, true); if (points) return points; } // Kindleフォーマットポイント const swatchElements = this.querySelectorAll(dom, ".swatchElement"); for (const element of swatchElements) { if (/Kindle/.test(element.innerText)) { const points = Utils.extractNumber(element.innerText, /([0-9,]+)pt/, 1, true); if (points) return points; } } // 通常ポイント const loyaltyElement = this.querySelector(dom, ".loyalty-points"); if (loyaltyElement) { const points = Utils.extractNumber(loyaltyElement.innerText, /[0-9,]+/, 0, true); if (points) return points; } return 0; } getISBNs(dom) { const isbns = new Set(); const links = this.querySelectorAll(dom, "#tmmSwatches a"); links.forEach((link) => { const href = link.getAttribute("href"); if (!href) return; const match = href.match(/\/(4[0-9]{8}[0-9X])/); if (match && Utils.validateISBN(match[1])) { isbns.add(match[1]); } }); return [...isbns]; } async isKDP(dom, storage) { const elements = this.querySelectorAll( dom, "#detailBullets_feature_div .a-list-item", ); for (const element of elements) { if (!/出版社/.test(element.innerText)) continue; const publisherElement = element.querySelector("span:nth-child(2)"); if (!publisherElement) return true; const match = publisherElement.innerText.match(/^[^;(]*/); if (!match?.[0]) return true; const publisher = match[0].trim(); Logger.log(`Publisher: ${publisher}`); if (COMMERCIAL_PUBLISHERS.some((p) => new RegExp(p).test(publisher))) { return false; } let publishersCache = storage.load("PUBLISHERS") || {}; if (publishersCache[publisher] !== undefined) { return !publishersCache[publisher]; } const hasPublisher = await APIClient.checkPublisherInNDL(publisher); publishersCache[publisher] = hasPublisher; storage.save("PUBLISHERS", publishersCache); return !hasPublisher; } return true; } getCampaigns(dom) { return this.querySelector(dom, "#multibuy-widget-container") ? ["まとめ買いキャンペーン"] : []; } getWishlistItemTitle(dom) { return this.querySelector(dom, 'a[id^="itemName_"]')?.innerText ?? null; } getWishlistItemASIN(dom) { const element = this.querySelector(dom, ".price-section"); const attribute = element?.getAttribute("data-item-prime-info"); try { return attribute ? JSON.parse(attribute).asin : undefined; } catch { return undefined; } } isWishlistKindleItem(dom) { return /Kindle版/.test(dom.innerText); } isItemProcessed(dom) { return dom.classList.contains(Config.CLASSES.PROCESSED); } isSearchKindleItem(dom) { return this.querySelectorAll(dom, "a.a-text-bold").some((el) => /^Kindle版/.test(el.innerHTML.trim()), ); } getSearchItemTitle(dom) { return this.querySelector(dom, "h2 > a")?.innerText.trim() ?? null; } getSearchItemASIN(dom) { return dom.getAttribute("data-asin"); } isSearchBulkBuy(dom) { return /まとめ買い/.test(dom.innerText); } } const Templates = { paperPriceRow: (paperPrice, discount, discountRate) => ` <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"> ¥${discount}(${discountRate}%) </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> `, pointsRow: (points, pointRate) => ` <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">${points}ポイント</span> <span class="a-size-base a-color-price">(${pointRate}%)</span> </span> </div> </td> </tr> `, processingIndicator: (message = "取得待ち") => `<div class="a-row ${Config.CLASSES.PROCESSING}" style="color:${Config.COLORS.PROCESSING}">${message}</div>`, priceInfo: (type, price) => ` <div> <span class="a-price-symbol">${type}:¥</span> <span class="a-price-whole">${price}</span> </div> `, priceInfoLarge: (type, price) => ` <div> <span class="a-price-symbol a-color-price a-size-large">${type}:¥</span> <span class="a-price-whole a-color-price a-size-large">${price}</span> </div> `, discountInfo: (discount, rate, color) => ` <div style="color:${color.color};background-color:${color.bgColor}"> <span class="a-price-symbol">割り引き:</span> <span class="a-price-whole">${discount}円( ${Math.round(rate)}%割引)</span> </div> `, pointInfo: (points, rate, color) => ` <div style="color:${color.color};background-color:${color.bgColor}"> <span class="a-price-symbol">ポイント:</span> <span class="a-price-whole">${points}ポイント(${Math.round(rate)}%還元)</span> </div> `, kindleUnlimited: () => ` <div> <span class="a-price-symbol a-text-bold">Kindle Unlimited対象</span> </div> `, campaign: (name) => ` <div> <span class="a-price-symbol a-text-bold">${name}</span> </div> `, kdpBadge: () => `<div class="a-size-medium" style="color:${Config.COLORS.KDP.color};background-color:${Config.COLORS.KDP.bgColor}">KDP</div>`, noPaperBook: () => `<span class="a-price-symbol" style="color:${Config.COLORS.ERROR}">紙の本:無し</span>`, unknownISBN: () => `<div class="a-size-medium" style="color:${Config.COLORS.ERROR}">ISBN不明</div>`, }; class ViewRenderer { static renderPaperPrice(dom, paperPrice, kindlePrice) { if (!paperPrice) return; paperPrice = Utils.calculateTaxIncludedPrice(paperPrice); const discount = paperPrice - kindlePrice; const discountRate = Math.round( Utils.calculateRate(discount, paperPrice), ); const existing = dom.querySelector(".print-list-price"); if (existing) existing.remove(); const target = dom.querySelector("#buybox tbody") || dom.querySelector("#buyOneClick tbody"); target?.insertAdjacentHTML( "afterbegin", Templates.paperPriceRow(paperPrice, discount, discountRate), ); } static renderPoints(dom, price, points) { if (dom.querySelector(".loyalty-points") || !points) return; const pointRate = Math.round(Utils.calculateRate(points, price)); const target = dom.querySelector("#buybox tbody") || dom.querySelector("#buyOneClick tbody"); target?.insertAdjacentHTML( "beforeend", Templates.pointsRow(points, pointRate), ); } static emphasizePrice(dom) { const label = dom.querySelector("tr.kindle-price td"); const price = dom.querySelector("tr.kindle-price span"); if (!label || !price) return; [label, price].forEach((el) => { el.classList.remove("a-color-secondary", "a-size-small"); el.classList.add("a-color-price", "a-text-bold", "a-size-medium"); }); } static renderListItem(dom, data, isWishlist = true) { const paperPrice = Utils.calculateTaxIncludedPrice(data.paperPrice); const kindlePrice = data.kindlePrice; const discount = paperPrice - kindlePrice; const discountRate = Utils.calculateRate(discount, paperPrice); const discountColor = Utils.getColorByRate(discountRate); const points = data.pointReturn; const pointRate = Utils.calculateRate(points, kindlePrice); const pointColor = Utils.getColorByRate(pointRate); let html = "<div>"; if (data.paperPrice) { html += Templates.priceInfo("紙の本", paperPrice); } else if (!isWishlist && data.isKdp) { html += Templates.kdpBadge(); } else if (!data.isbn) { html += isWishlist ? Templates.noPaperBook() : Templates.unknownISBN(); } html += Templates.priceInfoLarge("価格", kindlePrice); if (data.paperPrice) { html += Templates.discountInfo(discount, discountRate, discountColor); } html += Templates.pointInfo(points, pointRate, pointColor); if (data.campaigns) { data.campaigns.forEach((campaign) => { html += Templates.campaign(campaign); }); } // Kindle Unlimited if (isWishlist && data.isKindleUnlimited) { html += Templates.kindleUnlimited(); } html += "</div>"; if (isWishlist) { const priceSection = dom.querySelector(".price-section"); if (priceSection) priceSection.innerHTML = html; } else { let isChanged = false; dom.querySelectorAll("div.a-row.a-size-base").forEach((element) => { if (/ポイント|税込|購入/.test(element.innerText)) { element.remove(); } else if (/[¥\\]/.test(element.innerText) && !isChanged) { element.innerHTML = html; isChanged = true; } }); } } static updateProcessingIndicator(dom, message, isError = false) { const indicator = dom.querySelector(`.${Config.CLASSES.PROCESSING}`); if (!indicator) return; if (message) { const color = isError ? "red" : Config.COLORS.PROCESSING; indicator.innerHTML = `<div class="a-row ${Config.CLASSES.PROCESSING}" style="color:${color}">${message}</div>`; } else { indicator.remove(); dom.classList.add(Config.CLASSES.PROCESSED); } } } class ConcurrencyManager { constructor(maxConcurrent = Config.CONCURRENT_PROCESSES) { this.maxConcurrent = maxConcurrent; this.running = 0; this.queue = []; } async acquire() { if (this.running >= this.maxConcurrent) { await new Promise((resolve) => this.queue.push(resolve)); } this.running++; } release() { this.running--; const next = this.queue.shift(); if (next) next(); } async run(task) { await this.acquire(); try { return await task(); } finally { this.release(); } } } class BasePageHandler { constructor(storage, parser) { this.storage = storage; this.parser = parser; this.discoveries = []; this.observer = null; this.concurrencyManager = new ConcurrencyManager(); this.isRunning = false; this.processingSet = new Set(); } async initialize(dom) { await this.setupInitialItems(dom); this.setupObserver(dom); this.startProcessing(); } setupInitialItems(dom) { throw new Error("setupInitialItems must be implemented"); } setupObserver(dom) { throw new Error("setupObserver must be implemented"); } startProcessing() { if (this.isRunning) return; this.isRunning = true; this.processQueue(); } async processQueue() { while (this.isRunning) { const batch = this.extractBatch(); if (batch.length > 0) { const promises = batch.map((item) => this.concurrencyManager.run(() => this.safeProcessItem(item)), ); Promise.allSettled(promises).then(() => { Logger.log(`Batch of ${batch.length} items processed`); }); } await Utils.sleep( this.discoveries.length > 0 ? Config.PROCESS_INTERVAL_MS : Config.DISCOVERY_INTERVAL_MS, ); } } extractBatch() { const batch = []; const maxBatch = Math.min( Config.CONCURRENT_PROCESSES, this.discoveries.length, ); for (let i = 0; i < maxBatch; i++) { const item = this.discoveries.shift(); if (item) { const asin = this.getItemASIN(item); if (asin && !this.processingSet.has(asin)) { this.processingSet.add(asin); batch.push(item); } } } return batch; } getItemASIN(dom) { return ( dom.getAttribute?.("data-asin") || this.parser.getWishlistItemASIN?.(dom) || this.parser.getSearchItemASIN?.(dom) ); } async safeProcessItem(item) { const asin = this.getItemASIN(item); try { await this.processItem(item); } catch (error) { Logger.error(`Error processing item [${asin}]:`, error); ViewRenderer.updateProcessingIndicator( item, error.message || "エラーが発生しました", true, ); } finally { if (asin) { this.processingSet.delete(asin); } } } async fetchItemData(asin, title) { if (this.storage.isCacheActive(asin)) { Logger.log(`CACHE HIT: [${asin}] ${title}`); return this.storage.load(asin); } const maxRetries = 3; let lastError; for (let i = 0; i < maxRetries; i++) { try { Logger.log(`CACHE MISS: [${asin}] ${title} (attempt ${i + 1})`); const response = await HttpClient.get(API_URLS.AMAZON_PRODUCT(asin)); const itemDom = this.parser.parseHTML(response.response); const itemHandler = new ItemPageHandler(this.storage, this.parser); return await itemHandler.getItemInfo(itemDom); } catch (error) { lastError = error; if (i < maxRetries - 1) { await Utils.sleep(Math.pow(2, i) * 1000); } } } throw lastError; } addProcessingIndicator(dom, selector) { const element = dom.querySelector(selector); element?.insertAdjacentHTML( "afterbegin", Templates.processingIndicator(), ); } stop() { this.isRunning = false; this.observer?.disconnect(); } } class ItemPageHandler { constructor(storage, parser) { this.storage = storage; this.parser = parser; } async getItemInfo(dom) { if (this.parser.isAgeVerification(dom)) { throw new Error("年齢確認が必要です"); } if (!this.parser.isKindlePage(dom)) return null; const asin = this.parser.getASIN(dom); if (!asin) throw new Error("ASINが見つかりません"); const cachedData = this.storage.load(asin); const [bookInfo, kindleInfo] = await Promise.all([ this.getBookInfo(dom, cachedData), this.getKindleInfo(dom, cachedData), ]); return { asin, isbn: bookInfo.isbn, paperPrice: bookInfo.price, kindlePrice: kindleInfo.price, pointReturn: kindleInfo.point, isKdp: kindleInfo.isKdp, isKindleUnlimited: kindleInfo.isKindleUnlimited, campaigns: kindleInfo.campaigns, updatedAt: Date.now(), }; } async getBookInfo(dom, cachedData) { if (cachedData?.paperPrice != null) { return { isbn: cachedData.isbn, price: cachedData.paperPrice, }; } const isbns = this.parser.getISBNs(dom); Logger.log("ISBNs found:", isbns); if (isbns.length === 0) { return { isbn: null, price: null }; } const book = await APIClient.findLowestPriceBook(isbns); Logger.log("Lowest price book:", book); return { isbn: book.isbn, price: book.price }; } async getKindleInfo(dom, cachedData) { const [ kindlePrice, pointReturn, isKdp, isKindleUnlimited, campaigns, ] = await Promise.all([ this.parser.getKindlePrice(dom), this.parser.getPointReturn(dom), this.parser.isKDP(dom, this.storage), this.parser.isKindleUnlimited(dom), this.parser.getCampaigns(dom), ]); const info = { price: kindlePrice ?? cachedData?.kindlePrice, point: pointReturn, isKdp, isKindleUnlimited, campaigns, }; Logger.log("Kindle info:", info); return info; } clickPrompt(dom) { const prompt = dom.querySelector("#buyOneClick .a-expander-prompt"); prompt?.click(); } async process(dom) { ViewRenderer.emphasizePrice(dom); this.clickPrompt(dom); try { const data = await this.getItemInfo(dom); if (data) { this.storage.save(data.asin, data); ViewRenderer.renderPaperPrice(dom, data.paperPrice, data.kindlePrice); ViewRenderer.renderPoints(dom, data.kindlePrice, data.pointReturn); } } catch (error) { Logger.error("Item page processing error:", error); } } } class WishlistPageHandler extends BasePageHandler { async setupInitialItems(dom) { await this.push(dom.querySelectorAll(".g-item-sortable")); } setupObserver(dom) { this.observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === "childList") { this.push(mutation.addedNodes); } }); }); const targetNode = document.querySelector("#g-items"); targetNode && this.observer.observe(targetNode, { childList: true }); } async push(nodes) { for (const dom of Array.from(nodes).filter( (el) => el.nodeName === "LI", )) { const title = this.parser.getWishlistItemTitle(dom); const asin = this.parser.getWishlistItemASIN(dom); if (!this.parser.isWishlistKindleItem(dom) || !asin) { Logger.log(`DROP: [${asin}] ${title}`); continue; } Logger.log(`PUSH: [${asin}] ${title}`); this.addProcessingIndicator(dom, 'div[id^="itemInfo_"]'); this.discoveries.push(dom); } } async processItem(dom) { const indicator = dom.querySelector(`.${Config.CLASSES.PROCESSING}`); if (indicator) indicator.textContent = "取得中"; const title = this.parser.getWishlistItemTitle(dom); const asin = this.parser.getWishlistItemASIN(dom); Logger.log(`PROCESSING: [${asin}] ${title}`); if (this.parser.isItemProcessed(dom)) { ViewRenderer.updateProcessingIndicator(dom); return; } try { const data = await this.fetchItemData(asin, title); if (data) { this.storage.save(data.asin, data); ViewRenderer.renderListItem(dom, data, true); } ViewRenderer.updateProcessingIndicator(dom); Logger.log(`COMPLETE: [${asin}] ${title}`); } catch (error) { Logger.error(`ERROR: [${asin}] ${title}`, error); ViewRenderer.updateProcessingIndicator(dom, error.message, true); } } } class Application { constructor() { this.storage = new StorageManager(); this.parser = new AmazonDOMParser(); } detectPageType(url) { if (/\/(dp|gp)\//.test(url)) return "item"; if (/\/wishlist\//.test(url)) return "wishlist"; return null; } async run() { const url = location.href; const pageType = this.detectPageType(url); Logger.info(`Page type detected: ${pageType}`); this.storage.autoClean(); try { switch (pageType) { case "item": if (this.parser.isKindlePage(document)) { Logger.info("Processing item page..."); const itemHandler = new ItemPageHandler( this.storage, this.parser, ); await itemHandler.process(document); } break; case "wishlist": Logger.info("Processing wishlist page..."); const wishlistHandler = new WishlistPageHandler( this.storage, this.parser, ); await wishlistHandler.initialize(document); break; default: Logger.info("Page type not supported"); } } catch (error) { Logger.error("Application error:", error); } } } const app = new Application(); app.run(); })();