359 lines
33 KiB
JavaScript
359 lines
33 KiB
JavaScript
|
// ==UserScript==
|
||
|
// @name Kobo Price
|
||
|
// @namespace https://tertyshny.dev
|
||
|
// @description Find lowest book price on kobo.com
|
||
|
// @noframes
|
||
|
// @match https://www.kobo.com/*/*/ebook/*
|
||
|
// @match https://www.kobo.com/*/*/audiobook/*
|
||
|
// @require https://cdn.jsdelivr.net/npm/currency.js
|
||
|
// @require https://cdn.jsdelivr.net/npm/date-fns/cdn.min.js
|
||
|
// @run-at document-end
|
||
|
// @version 1
|
||
|
// @icon data:image/png;base64,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
|
||
|
// ==/UserScript==
|
||
|
|
||
|
globalThis.l = (...m) => console.log("KOBOPRICE", ...m);
|
||
|
|
||
|
const COUNTRIES = [
|
||
|
{ countryCode: "ww", currencyCode: "usd" },
|
||
|
{ countryCode: "ca", currencyCode: "cad" },
|
||
|
{ countryCode: "us", currencyCode: "usd" },
|
||
|
{ countryCode: "in", currencyCode: "inr" },
|
||
|
{ countryCode: "za", currencyCode: "zar" },
|
||
|
{ countryCode: "au", currencyCode: "aud" },
|
||
|
{ countryCode: "hk", currencyCode: "hkd" },
|
||
|
{ countryCode: "jp", currencyCode: "jpy" },
|
||
|
{ countryCode: "my", currencyCode: "myr" },
|
||
|
{ countryCode: "nz", currencyCode: "nzd" },
|
||
|
{ countryCode: "ph", currencyCode: "php" },
|
||
|
{ countryCode: "sg", currencyCode: "sgd" },
|
||
|
{ countryCode: "tw", currencyCode: "twd" },
|
||
|
{ countryCode: "th", currencyCode: "usd" },
|
||
|
{ countryCode: "at", currencyCode: "eur" },
|
||
|
{ countryCode: "be", currencyCode: "eur" },
|
||
|
{ countryCode: "cy", currencyCode: "eur" },
|
||
|
{ countryCode: "cz", currencyCode: "czk" },
|
||
|
{ countryCode: "dk", currencyCode: "dkk" },
|
||
|
{ countryCode: "ee", currencyCode: "eur" },
|
||
|
{ countryCode: "fi", currencyCode: "eur" },
|
||
|
{ countryCode: "fr", currencyCode: "eur" },
|
||
|
{ countryCode: "de", currencyCode: "eur" },
|
||
|
{ countryCode: "gr", currencyCode: "eur" },
|
||
|
{ countryCode: "ie", currencyCode: "eur" },
|
||
|
{ countryCode: "it", currencyCode: "eur" },
|
||
|
{ countryCode: "lt", currencyCode: "eur" },
|
||
|
{ countryCode: "lu", currencyCode: "eur" },
|
||
|
{ countryCode: "mt", currencyCode: "eur" },
|
||
|
{ countryCode: "nl", currencyCode: "eur" },
|
||
|
{ countryCode: "no", currencyCode: "nok" },
|
||
|
{ countryCode: "pl", currencyCode: "pln" },
|
||
|
{ countryCode: "pt", currencyCode: "eur" },
|
||
|
{ countryCode: "ro", currencyCode: "ron" },
|
||
|
{ countryCode: "sk", currencyCode: "eur" },
|
||
|
{ countryCode: "si", currencyCode: "eur" },
|
||
|
{ countryCode: "es", currencyCode: "eur" },
|
||
|
{ countryCode: "se", currencyCode: "sek" },
|
||
|
{ countryCode: "ch", currencyCode: "chf" },
|
||
|
{ countryCode: "tr", currencyCode: "try" },
|
||
|
{ countryCode: "gb", currencyCode: "gbp" },
|
||
|
{ countryCode: "ar", currencyCode: "usd" },
|
||
|
{ countryCode: "br", currencyCode: "brl" },
|
||
|
{ countryCode: "cl", currencyCode: "clp" },
|
||
|
{ countryCode: "co", currencyCode: "cop" },
|
||
|
{ countryCode: "mx", currencyCode: "mxn" },
|
||
|
{ countryCode: "pe", currencyCode: "pen" },
|
||
|
];
|
||
|
|
||
|
const CACHE_PREFIX = "KOBOPRICE";
|
||
|
|
||
|
const initCache = () => {
|
||
|
if (!localStorage.getItem(CACHE_PREFIX)) {
|
||
|
setState({ books: {}, rates: null });
|
||
|
}
|
||
|
};
|
||
|
const getState = () => JSON.parse(localStorage.getItem(CACHE_PREFIX));
|
||
|
const setState = (s) => localStorage.setItem(CACHE_PREFIX, JSON.stringify(s));
|
||
|
const getRates = () => getState().rates;
|
||
|
const cacheRates = (rates) => setState({ ...getState(), rates });
|
||
|
const getBookPrice = (url) => getState().books[url];
|
||
|
const cacheBookPrice = (price, url) => {
|
||
|
const state = getState();
|
||
|
state.books[url] = price;
|
||
|
setState(state);
|
||
|
};
|
||
|
|
||
|
const bookUrl = (country) => {
|
||
|
const urlPattern = /^https:\/\/www\.kobo\.com\/../;
|
||
|
const newPath = `https://www.kobo.com/${country}`;
|
||
|
const url = window.location.href.replace(urlPattern, newPath);
|
||
|
|
||
|
l("url for country", country, url);
|
||
|
|
||
|
return url;
|
||
|
};
|
||
|
|
||
|
const convertPrice = async (price, curr, rate) => {
|
||
|
l("rate for", curr, "is", rate);
|
||
|
|
||
|
let useVedic = false;
|
||
|
|
||
|
switch (curr) {
|
||
|
case "inr":
|
||
|
case "php": {
|
||
|
useVedic = true;
|
||
|
break;
|
||
|
}
|
||
|
case "jpy": {
|
||
|
break;
|
||
|
}
|
||
|
case "zar": {
|
||
|
price = price.replace(",", ".");
|
||
|
break;
|
||
|
}
|
||
|
case "dkk": {
|
||
|
price = price.replace("kr.", "").replace(",", ".");
|
||
|
break;
|
||
|
}
|
||
|
case "pen": {
|
||
|
price = price.replace("S/.", "");
|
||
|
break;
|
||
|
}
|
||
|
case "clp":
|
||
|
case "cop":
|
||
|
case "twd": {
|
||
|
break;
|
||
|
}
|
||
|
default:
|
||
|
price = price.replace(/[^\d,.-]/, "").replace(",", ".");
|
||
|
}
|
||
|
|
||
|
const convertedPrice = currency(price, { useVedic }).divide(rate);
|
||
|
|
||
|
l("converted price for", curr, convertedPrice);
|
||
|
|
||
|
return {
|
||
|
intValue: convertedPrice.intValue,
|
||
|
formatted: convertedPrice.format(),
|
||
|
};
|
||
|
};
|
||
|
|
||
|
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,
|
||
|
});
|
||
|
});
|
||
|
|
||
|
const extractPrice = 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);
|
||
|
|
||
|
const price = await observePriceOnPage(iframe.contentDocument.body, url);
|
||
|
|
||
|
document.body.removeChild(iframe);
|
||
|
|
||
|
return price;
|
||
|
} catch (e) {
|
||
|
l("getPriceForCountry", e, url);
|
||
|
return "";
|
||
|
}
|
||
|
};
|
||
|
|
||
|
const baseCurrency = "usd";
|
||
|
|
||
|
const loadCurrencyRates = async () => {
|
||
|
try {
|
||
|
const url = `https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/${baseCurrency}.json`;
|
||
|
l("loading currency rates", url);
|
||
|
const res = await fetch(url);
|
||
|
return await res.json();
|
||
|
} catch (e) {
|
||
|
l("loadCurrencyRates", e);
|
||
|
return null;
|
||
|
}
|
||
|
};
|
||
|
|
||
|
const getRate = async (currency) => {
|
||
|
const cached = getRates();
|
||
|
|
||
|
if (cached && dateFns.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];
|
||
|
};
|
||
|
|
||
|
const bookPriceFor = async (country) => {
|
||
|
l("looking price for", country.countryCode);
|
||
|
|
||
|
const url = bookUrl(country.countryCode);
|
||
|
|
||
|
const fromCache = getBookPrice(url);
|
||
|
|
||
|
if (fromCache && dateFns.isToday(fromCache.cachedAt)) {
|
||
|
l("found book price in cache", fromCache);
|
||
|
return fromCache;
|
||
|
}
|
||
|
|
||
|
const countryPrice = await extractPrice(url);
|
||
|
|
||
|
let convertedPrice;
|
||
|
|
||
|
if (countryPrice) {
|
||
|
l("found price", countryPrice, url);
|
||
|
const rate = await getRate(country.currencyCode);
|
||
|
convertedPrice = await convertPrice(
|
||
|
countryPrice,
|
||
|
country.currencyCode,
|
||
|
rate,
|
||
|
);
|
||
|
}
|
||
|
|
||
|
const newPrice = {
|
||
|
...country,
|
||
|
countryPrice,
|
||
|
convertedPrice,
|
||
|
url,
|
||
|
cachedAt: dateFns.format(new Date(), "yyyy-MM-dd"),
|
||
|
};
|
||
|
|
||
|
cacheBookPrice(newPrice, url);
|
||
|
|
||
|
return newPrice;
|
||
|
};
|
||
|
|
||
|
const sortPrices = (prices) =>
|
||
|
prices.sort(
|
||
|
(a, b) =>
|
||
|
(a?.convertedPrice?.intValue || Infinity) -
|
||
|
(b?.convertedPrice?.intValue || Infinity),
|
||
|
);
|
||
|
|
||
|
const getPrices = 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 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.style.display = "flex";
|
||
|
link.style.justifyContent = "space-between";
|
||
|
const oldPrice = document.createElement("p");
|
||
|
oldPrice.innerText = `${price.countryCode.toUpperCase()}: ${price?.countryPrice || "NO PRICE"}`;
|
||
|
const newPrice = document.createElement("p");
|
||
|
newPrice.innerText = price?.convertedPrice?.formatted ?? "NO PRICE";
|
||
|
newPrice.style.fontWeight = "bold";
|
||
|
link.appendChild(oldPrice);
|
||
|
link.appendChild(newPrice);
|
||
|
container.appendChild(link);
|
||
|
});
|
||
|
};
|
||
|
|
||
|
const createPricesContainer = () => {
|
||
|
const pricingActionContainers = document.querySelectorAll(".pricing-details");
|
||
|
|
||
|
l("all pricing containers", pricingActionContainers);
|
||
|
|
||
|
let visible;
|
||
|
for (const node of pricingActionContainers) {
|
||
|
if (node.checkVisibility()) {
|
||
|
l("found visible pricing container", node);
|
||
|
visible = node;
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const container = document.createElement("div");
|
||
|
|
||
|
container.style.display = "flex";
|
||
|
container.style.flexDirection = "column";
|
||
|
container.style.border = "1px solid black";
|
||
|
container.style.padding = "10px";
|
||
|
|
||
|
return visible.parentNode.insertBefore(container, visible);
|
||
|
};
|
||
|
|
||
|
async function main() {
|
||
|
const container = createPricesContainer();
|
||
|
|
||
|
container.innerText = "LOADING PRICES...";
|
||
|
|
||
|
try {
|
||
|
initCache();
|
||
|
|
||
|
const countriesPrice = await getPrices();
|
||
|
|
||
|
l("country prices", countriesPrice);
|
||
|
|
||
|
sortPrices(countriesPrice);
|
||
|
|
||
|
l("sorted prices", countriesPrice);
|
||
|
|
||
|
showPrices(container, countriesPrice);
|
||
|
} catch (e) {
|
||
|
l("error", e);
|
||
|
container.innerText = "FAILED TO LOAD PRICES. CHECK CONSOLE FOR MORE INFO";
|
||
|
} finally {
|
||
|
l("done");
|
||
|
}
|
||
|
}
|
||
|
|
||
|
l("starting...");
|
||
|
void main();
|