partially working solution
This commit is contained in:
parent
5afe932247
commit
64c26ab368
12 changed files with 283 additions and 120 deletions
|
@ -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",
|
||||
|
|
50
lib/bookPriceFor.js
Normal file
50
lib/bookPriceFor.js
Normal file
|
@ -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;
|
||||
};
|
6
lib/bookUrl.js
Normal file
6
lib/bookUrl.js
Normal file
|
@ -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);
|
||||
};
|
18
lib/cache.js
Normal file
18
lib/cache.js
Normal file
|
@ -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);
|
||||
};
|
|
@ -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" },
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
48
lib/extractPriceFrom.js
Normal file
48
lib/extractPriceFrom.js
Normal file
|
@ -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 "";
|
||||
}
|
||||
};
|
|
@ -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));
|
||||
};
|
||||
});
|
101
lib/index.js
101
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 {
|
||||
|
|
41
lib/rates.js
Normal file
41
lib/rates.js
Normal file
|
@ -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];
|
||||
};
|
36
package-lock.json
generated
36
package-lock.json
generated
|
@ -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": {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue