From 64c26ab368dbcfeb9955817bdc28c1e3a6c3dadc Mon Sep 17 00:00:00 2001 From: Gregory Tertyshny Date: Sun, 14 Jul 2024 22:36:08 +0300 Subject: [PATCH] partially working solution --- eslint.config.js | 10 ++-- lib/bookPriceFor.js | 50 +++++++++++++++++++ lib/bookUrl.js | 6 +++ lib/cache.js | 18 +++++++ lib/countries.js | 10 ++-- lib/currency.js | 20 -------- lib/extractPriceFrom.js | 48 ++++++++++++++++++ lib/getPriceForCountry.js | 56 --------------------- lib/index.js | 101 +++++++++++++++++++++++++++++++------- lib/rates.js | 41 ++++++++++++++++ package-lock.json | 36 +++++++++----- package.json | 7 +-- 12 files changed, 283 insertions(+), 120 deletions(-) create mode 100644 lib/bookPriceFor.js create mode 100644 lib/bookUrl.js create mode 100644 lib/cache.js delete mode 100644 lib/currency.js create mode 100644 lib/extractPriceFrom.js delete mode 100644 lib/getPriceForCountry.js create mode 100644 lib/rates.js diff --git a/eslint.config.js b/eslint.config.js index 7538115..54d3a54 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,12 +3,12 @@ import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended" import globals from "globals"; export default [ - { - ignores: ["dist"] - }, { - ...js.configs.recommended, - ...eslintPluginPrettierRecommended, + ignores: ["dist"], + }, + eslintPluginPrettierRecommended, + js.configs.recommended, + { rules: { "no-unused-vars": "warn", "no-undef": "warn", diff --git a/lib/bookPriceFor.js b/lib/bookPriceFor.js new file mode 100644 index 0000000..29c0fe4 --- /dev/null +++ b/lib/bookPriceFor.js @@ -0,0 +1,50 @@ +import currency from "currency.js"; +import { bookUrl } from "./bookUrl"; +import { extractPriceFrom } from "./extractPriceFrom"; +import { getRate } from "./rates"; +import { cacheBookPrice, getBookPrice } from "./cache"; +import { format, isToday } from "date-fns"; + +export const bookPriceFor = async (country) => { + l("looking price for", country.countryCode); + + const url = bookUrl(country.countryCode); + + const fromCache = getBookPrice(url); + + if (fromCache && isToday(fromCache.cachedAt)) { + l("found book price in cache", fromCache); + return fromCache; + } + + const countryPrice = await extractPriceFrom(url); + + let convertedPrice; + + if (countryPrice) { + const rate = await getRate(country.currencyCode); + + l("rate for", country.currencyCode, "is", rate); + + convertedPrice = currency(countryPrice).divide(rate); + + convertedPrice = { + intValue: convertedPrice.intValue, + format: convertedPrice.format(), + }; + + l("converted price for", country.currencyCode, convertedPrice); + } + + const newPrice = { + ...country, + countryPrice, + convertedPrice, + url, + cachedAt: format(new Date(), "yyyy-MM-dd"), + }; + + cacheBookPrice(newPrice, url); + + return newPrice; +}; diff --git a/lib/bookUrl.js b/lib/bookUrl.js new file mode 100644 index 0000000..81e2572 --- /dev/null +++ b/lib/bookUrl.js @@ -0,0 +1,6 @@ +export const bookUrl = (country) => { + const urlPattern = /^https:\/\/www\.kobo\.com\/../; + const newPath = `https://www.kobo.com/${country}`; + + return window.location.href.replace(urlPattern, newPath); +}; diff --git a/lib/cache.js b/lib/cache.js new file mode 100644 index 0000000..2b348d1 --- /dev/null +++ b/lib/cache.js @@ -0,0 +1,18 @@ +const CACHE_PREFIX = "KOBOPRICE"; + +export const initCache = () => { + if (!localStorage.getItem(CACHE_PREFIX)) { + setState({ books: {}, rates: null }); + } +}; +export const getState = () => JSON.parse(localStorage.getItem(CACHE_PREFIX)); +export const setState = (s) => + localStorage.setItem(CACHE_PREFIX, JSON.stringify(s)); +export const getRates = () => getState().rates; +export const cacheRates = (rates) => setState({ ...getState(), rates }); +export const getBookPrice = (url) => getState().books[url]; +export const cacheBookPrice = (price, url) => { + const state = getState(); + state.books[url] = price; + setState(state); +}; diff --git a/lib/countries.js b/lib/countries.js index 3aec245..f08eb7e 100644 --- a/lib/countries.js +++ b/lib/countries.js @@ -1,12 +1,12 @@ export const COUNTRIES = [ { countryCode: "ww", currencyCode: "usd" }, { countryCode: "ca", currencyCode: "cad" }, - // { countryCode: "us", currencyCode: "usd" }, + { countryCode: "us", currencyCode: "usd" }, { countryCode: "in", currencyCode: "inr" }, - // { countryCode: "za", currencyCode: "zar" }, - // { countryCode: "au", currencyCode: "aud" }, - // { countryCode: "hk", currencyCode: "hkd" }, - // { countryCode: "jp", currencyCode: "EUR" }, + { countryCode: "za", currencyCode: "zar" }, + { countryCode: "au", currencyCode: "aud" }, + { countryCode: "hk", currencyCode: "hkd" }, + { countryCode: "jp", currencyCode: "JPY" }, // { countryCode: "my", currencyCode: "EUR" }, // { countryCode: "nz", currencyCode: "EUR" }, // { countryCode: "ph", currencyCode: "EUR" }, diff --git a/lib/currency.js b/lib/currency.js deleted file mode 100644 index 0dfc82a..0000000 --- a/lib/currency.js +++ /dev/null @@ -1,20 +0,0 @@ -import currency from "currency.js"; - -export const baseCurrency = "usd"; - -export const convertCurrency = async (price, outCurrency, rates) => { - return currency(price).divide(rates[baseCurrency][outCurrency]); -}; - -export const loadCurrencyRates = async () => { - l("loading currency rates"); - try { - const res = await fetch( - `https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/${baseCurrency}.json`, - ); - return await res.json(); - } catch (e) { - l("error", e); - return null; - } -}; diff --git a/lib/extractPriceFrom.js b/lib/extractPriceFrom.js new file mode 100644 index 0000000..5a4196e --- /dev/null +++ b/lib/extractPriceFrom.js @@ -0,0 +1,48 @@ +const timeout = (duration) => new Promise((r) => setTimeout(r, duration)); + +const observePriceOnPage = (page) => + new Promise((res, rej) => { + timeout(10000).then(() => rej("price not found")); + + var observer = new MutationObserver(() => { + const price = page.querySelector( + ".primary-right-container .pricing-details .active-price span", + ).textContent; + + if (price) { + l("found price", price); + observer.disconnect(); + res(price); + } + }); + + observer.observe(page, { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }); + }); + +export const extractPriceFrom = async (url) => { + try { + l("going to", url); + + const iframe = document.createElement("iframe"); + + iframe.src = url; + + iframe.hidden = true; + + document.body.append(iframe); + + await new Promise((res) => (iframe.contentWindow.onload = res)); + + l("starting observing price on", url); + + return await observePriceOnPage(iframe.contentDocument.body, url); + } catch (e) { + l("getPriceForCountry", e, url); + return ""; + } +}; diff --git a/lib/getPriceForCountry.js b/lib/getPriceForCountry.js deleted file mode 100644 index 5c08b14..0000000 --- a/lib/getPriceForCountry.js +++ /dev/null @@ -1,56 +0,0 @@ -const timeout = (duration) => new Promise((r) => setTimeout(r, duration)); - -const observePriceOnPage = (page) => - new Promise((res, rej) => { - timeout(10000).then(() => rej("")); - - var observer = new MutationObserver(() => { - const price = page - .querySelector(".active-price") - .querySelector("span").textContent; - if (price) { - observer.disconnect(); - res(price); - } - }); - - observer.observe(page, { - attributes: true, - childList: true, - characterData: true, - subtree: true, - }); - }); - -const bookUrlForCountry = (country) => { - const urlPattern = /^https:\/\/www\.kobo\.com\/../; - const newPath = `https://www.kobo.com/${country}`; - - return window.location.href.replace(urlPattern, newPath); -}; - -export const getPriceForCountry = (country) => - new Promise((res) => { - const url = bookUrlForCountry(country); - - l("going to", url); - - const iframe = document.createElement("iframe"); - - iframe.src = url; - - iframe.hidden = true; - - document.body.append(iframe); - - iframe.contentWindow.onload = () => { - l("starting observing price on", url); - observePriceOnPage(iframe.contentDocument.body, url) - .then(res) - .catch(() => { - l(`failed to find price for ${url}`); - res(""); - }) - .finally(() => document.body.removeChild(iframe)); - }; - }); diff --git a/lib/index.js b/lib/index.js index 5ebee77..0c6e442 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,29 +1,94 @@ -import './logger' +import "./logger"; import { COUNTRIES } from "./countries"; -import { getPriceForCountry } from "./getPriceForCountry"; -import { loadCurrencyRates, convertCurrency } from "./currency"; +import { initCache } from "./cache"; +import { bookPriceFor } from "./bookPriceFor"; +/* +TODO: +- not all currencies parsed as expected: + https://www.kobo.com/za/en/ebook/nine-minds + ZA: R300,60 -> $1,674.58 + JP: 2,246 円 -> $Infinity +- publish to stores +- check in chrome +- more durable source for rates +- React for UI +- More informative, show loading progress +- clear stale cache +*/ + +const createPricesContainer = () => { + const pricingActionContainer = document + .querySelector(".primary-right-container") + .querySelector(".pricing-details") + .querySelector(".action-container"); + + const container = document.createElement("div"); + + container.style.display = "flex"; + container.style.flexDirection = "column"; + + return pricingActionContainer.parentNode.insertBefore( + container, + pricingActionContainer, + ); +}; + +const getCountriesPrice = async () => { + const prices = []; + for (const country of COUNTRIES) { + // intentionally blocking execution + // to resolve sequentially. + // It should prevent DOS and triggering captcha + prices.push(await bookPriceFor(country)); + } + + return prices; +}; + +const formatPrice = (price) => { + const convertedPrice = price?.convertedPrice?.format ?? "NO PRICE"; + + const countryPrice = price?.countryPrice ?? "NO PRICE"; + + return `${price.countryCode.toUpperCase()}: ${countryPrice} -> ${convertedPrice}`; +}; + +const sortPrices = (prices) => + prices.sort( + (a, b) => + (a?.convertedPrice?.intValue || Infinity) - + (b?.convertedPrice?.intValue || Infinity), + ); + +const showPrices = (container, prices) => { + container.innerText = null; + prices.forEach((price) => { + const link = document.createElement("a"); + link.href = price.url; + link.target = "_blank"; + link.style.marginBottom = "5px"; + link.innerText = formatPrice(price); + container.appendChild(link); + }); +}; async function main() { try { - const rates = await loadCurrencyRates(); + initCache(); - l('currency rates', rates) + const container = createPricesContainer(); - const prices = await Promise.all( - COUNTRIES.map(async (c) => { - l("looking price for", c.countryCode); - const originalPrice = await getPriceForCountry(c.countryCode); - const convertedPrice = await convertCurrency( - originalPrice, - c.currencyCode, - rates, - ); + container.innerText = "LOADING PRICES..."; - return { ...c, originalPrice, convertedPrice }; - }), - ); + const countriesPrice = await getCountriesPrice(); - l(prices); + l("country prices", countriesPrice); + + sortPrices(countriesPrice); + + l("sorted prices", countriesPrice); + + showPrices(container, countriesPrice); } catch (e) { l("error", e); } finally { diff --git a/lib/rates.js b/lib/rates.js new file mode 100644 index 0000000..f862684 --- /dev/null +++ b/lib/rates.js @@ -0,0 +1,41 @@ +import { isToday } from "date-fns"; +import { getRates, cacheRates } from "./cache"; + +export const baseCurrency = "usd"; + +export const loadCurrencyRates = async () => { + l("loading currency rates"); + + try { + const res = await fetch( + `https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/${baseCurrency}.json`, + ); + return await res.json(); + } catch (e) { + l("loadCurrencyRates", e); + return null; + } +}; + +export const getRate = async (currency) => { + const cached = getRates(); + + if (cached && isToday(cached.date)) { + l("found rates in cache", cached); + + return cached[baseCurrency][currency]; + } + + const newRates = await loadCurrencyRates(); + + if (!newRates) { + l("failed to download rates"); + return 0; + } + + l("new rates", newRates); + + cacheRates(newRates); + + return newRates[baseCurrency][currency]; +}; diff --git a/package-lock.json b/package-lock.json index afc3b4b..8b4f66c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "currency.js": "^2.0.4" + "currency.js": "^2.0.4", + "date-fns": "^3.6.0" }, "devDependencies": { "@eslint/js": "^9.6.0", @@ -2145,6 +2146,22 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/concurrently/node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/concurrently/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -2288,19 +2305,12 @@ } }, "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, "node_modules/debounce": { diff --git a/package.json b/package.json index 7dc9790..35c664e 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "lint": "eslint --fix", - "build": "esbuild lib/index.js --bundle --minify --sourcemap --target=chrome58,firefox57,safari11,edge16 --outfile=dist/index.js", + "build": "esbuild lib/index.js --bundle --minify --sourcemap --target=chrome58,firefox57,safari11 --outfile=dist/index.js", "watch-build": "npm run build -- --watch", - "watch-ext": "web-ext run", + "watch-ext": "web-ext run --start-url kobo.com", "watch": "concurrently npm:watch-build npm:watch-ext" }, "author": "", @@ -26,6 +26,7 @@ "web-ext": "^8.2.0" }, "dependencies": { - "currency.js": "^2.0.4" + "currency.js": "^2.0.4", + "date-fns": "^3.6.0" } }