partially working solution

This commit is contained in:
Gregory Tertyshny 2024-07-14 22:36:08 +03:00
parent 5afe932247
commit 64c26ab368
12 changed files with 283 additions and 120 deletions

View file

@ -3,12 +3,12 @@ import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"
import globals from "globals"; import globals from "globals";
export default [ export default [
{
ignores: ["dist"]
},
{ {
...js.configs.recommended, ignores: ["dist"],
...eslintPluginPrettierRecommended, },
eslintPluginPrettierRecommended,
js.configs.recommended,
{
rules: { rules: {
"no-unused-vars": "warn", "no-unused-vars": "warn",
"no-undef": "warn", "no-undef": "warn",

50
lib/bookPriceFor.js Normal file
View 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
View 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
View 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);
};

View file

@ -1,12 +1,12 @@
export const COUNTRIES = [ export const COUNTRIES = [
{ countryCode: "ww", currencyCode: "usd" }, { countryCode: "ww", currencyCode: "usd" },
{ countryCode: "ca", currencyCode: "cad" }, { countryCode: "ca", currencyCode: "cad" },
// { countryCode: "us", currencyCode: "usd" }, { countryCode: "us", currencyCode: "usd" },
{ countryCode: "in", currencyCode: "inr" }, { countryCode: "in", currencyCode: "inr" },
// { countryCode: "za", currencyCode: "zar" }, { countryCode: "za", currencyCode: "zar" },
// { countryCode: "au", currencyCode: "aud" }, { countryCode: "au", currencyCode: "aud" },
// { countryCode: "hk", currencyCode: "hkd" }, { countryCode: "hk", currencyCode: "hkd" },
// { countryCode: "jp", currencyCode: "EUR" }, { countryCode: "jp", currencyCode: "JPY" },
// { countryCode: "my", currencyCode: "EUR" }, // { countryCode: "my", currencyCode: "EUR" },
// { countryCode: "nz", currencyCode: "EUR" }, // { countryCode: "nz", currencyCode: "EUR" },
// { countryCode: "ph", currencyCode: "EUR" }, // { countryCode: "ph", currencyCode: "EUR" },

View file

@ -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
View 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 "";
}
};

View file

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

View file

@ -1,29 +1,94 @@
import './logger' import "./logger";
import { COUNTRIES } from "./countries"; import { COUNTRIES } from "./countries";
import { getPriceForCountry } from "./getPriceForCountry"; import { initCache } from "./cache";
import { loadCurrencyRates, convertCurrency } from "./currency"; 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() { async function main() {
try { try {
const rates = await loadCurrencyRates(); initCache();
l('currency rates', rates) const container = createPricesContainer();
const prices = await Promise.all( container.innerText = "LOADING PRICES...";
COUNTRIES.map(async (c) => {
l("looking price for", c.countryCode);
const originalPrice = await getPriceForCountry(c.countryCode);
const convertedPrice = await convertCurrency(
originalPrice,
c.currencyCode,
rates,
);
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) { } catch (e) {
l("error", e); l("error", e);
} finally { } finally {

41
lib/rates.js Normal file
View 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
View file

@ -9,7 +9,8 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"currency.js": "^2.0.4" "currency.js": "^2.0.4",
"date-fns": "^3.6.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.6.0", "@eslint/js": "^9.6.0",
@ -2145,6 +2146,22 @@
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1" "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": { "node_modules/concurrently/node_modules/supports-color": {
"version": "8.1.1", "version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@ -2288,19 +2305,12 @@
} }
}, },
"node_modules/date-fns": { "node_modules/date-fns": {
"version": "2.30.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"dev": true,
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": { "funding": {
"type": "opencollective", "type": "github",
"url": "https://opencollective.com/date-fns" "url": "https://github.com/sponsors/kossnocorp"
} }
}, },
"node_modules/debounce": { "node_modules/debounce": {

View file

@ -7,9 +7,9 @@
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint --fix", "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-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" "watch": "concurrently npm:watch-build npm:watch-ext"
}, },
"author": "", "author": "",
@ -26,6 +26,7 @@
"web-ext": "^8.2.0" "web-ext": "^8.2.0"
}, },
"dependencies": { "dependencies": {
"currency.js": "^2.0.4" "currency.js": "^2.0.4",
"date-fns": "^3.6.0"
} }
} }