Compare commits

..

10 commits

22 changed files with 435 additions and 148 deletions

2
.gitignore vendored
View file

@ -1,5 +1,5 @@
node_modules node_modules
dist
.vscode .vscode
web-ext-artifacts web-ext-artifacts
.amo-upload-uuid .amo-upload-uuid
dist

30
README.md Normal file
View file

@ -0,0 +1,30 @@
# :dollar: Kobo Price
Helps you find the lowest book price on kobo.com.
![example](./img/example.gif)
## Installation
You can install this script in several ways:
- [Firefox add-on](https://addons.mozilla.org/en-US/firefox/addon/kobo-price)
- [Chrome extension](https://chromewebstore.google.com/detail/kobo-price/gjiadglcgiidfphjijgeellagidbkiah)
- [Userscript](https://raw.githubusercontent.com/fotonmoton/koboprice/master/userscript/koboprice.user.js)
## Usage
On kobo.com, the book price for each country could be different. This script will fetch all book prices, convert them to USD at today's rate, sort them, and show to you as a list. Then you can change your billing address to the country whose price you like and proceed to checkout.
- Go to the book/audiobook page you want to buy
- wait a couple of minutes until all the prices are loaded (you should see the progress on the right side of the book page)
- get the list of prices sorted and converted to USD
## Build
All artifacts are based on the `dist/index.js` bundle. To get it, run `npm run bundle`. To get the web extension archive, run `npm run build-ext`. To get the userscript, run `npm run build-userscript`.
To get all the above:
```sh
npm install && npm build-all
```

View file

@ -4,7 +4,7 @@ import globals from "globals";
export default [ export default [
{ {
ignores: ["dist"], ignores: ["dist", "userscript"],
}, },
eslintPluginPrettierRecommended, eslintPluginPrettierRecommended,
js.configs.recommended, js.configs.recommended,

View file

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

BIN
icons/16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
icons/32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
icons/48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
img/example.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

View file

@ -1,5 +1,5 @@
import { bookUrl } from "./bookUrl"; import { bookUrl } from "./bookUrl";
import { extractPriceFrom } from "./extractPriceFrom"; import { extractPrice } from "./extractPrice";
import { getRate } from "./rates"; import { getRate } from "./rates";
import { convertPrice } from "./convertPrice"; import { convertPrice } from "./convertPrice";
import { cacheBookPrice, getBookPrice } from "./cache"; import { cacheBookPrice, getBookPrice } from "./cache";
@ -17,7 +17,7 @@ export const bookPriceFor = async (country) => {
return fromCache; return fromCache;
} }
const countryPrice = await extractPriceFrom(url); const countryPrice = await extractPrice(url);
let convertedPrice; let convertedPrice;

View file

@ -1,6 +1,9 @@
export const bookUrl = (country) => { export const bookUrl = (country) => {
const urlPattern = /^https:\/\/www\.kobo\.com\/../; const urlPattern = /^https:\/\/www\.kobo\.com\/../;
const newPath = `https://www.kobo.com/${country}`; const newPath = `https://www.kobo.com/${country}`;
const url = window.location.href.replace(urlPattern, newPath);
return window.location.href.replace(urlPattern, newPath); l("url for country", country, url);
return url;
}; };

View file

@ -1,18 +1,37 @@
import { COUNTRIES } from "./countries";
const CACHE_PREFIX = "KOBOPRICE"; const CACHE_PREFIX = "KOBOPRICE";
const initCountries = () =>
COUNTRIES.reduce(
(acc, { countryCode }) => ({ ...acc, [countryCode]: true }),
{},
);
export const initCache = () => { export const initCache = () => {
if (!localStorage.getItem(CACHE_PREFIX)) { if (!localStorage.getItem(CACHE_PREFIX)) {
setState({ books: {}, rates: null }); setState({
} books: {},
rates: null,
countries: initCountries(),
});
}
}; };
export const getState = () => JSON.parse(localStorage.getItem(CACHE_PREFIX)); export const getState = () => JSON.parse(localStorage.getItem(CACHE_PREFIX));
export const setState = (s) => export const setState = (s) =>
localStorage.setItem(CACHE_PREFIX, JSON.stringify(s)); localStorage.setItem(CACHE_PREFIX, JSON.stringify(s));
export const getRates = () => getState().rates; export const getRates = () => getState().rates;
export const cacheRates = (rates) => setState({ ...getState(), rates }); export const cacheRates = (rates) => setState({ ...getState(), rates });
export const getBookPrice = (url) => getState().books[url]; export const getBookPrice = (url) => getState().books[url];
export const cacheBookPrice = (price, url) => { export const cacheBookPrice = (price, url) => {
const state = getState(); const state = getState();
state.books[url] = price; state.books[url] = price;
setState(state); setState(state);
};
export const getSelectedCountries = () =>
getState().countries || initCountries();
export const cacheSelectedCountries = (countries) => {
const state = getState();
state.countries = countries;
setState(state);
}; };

View file

@ -26,9 +26,14 @@ export const convertPrice = async (price, curr, rate) => {
price = price.replace("S/.", ""); price = price.replace("S/.", "");
break; break;
} }
case "try": {
price = price.replace(".", "").replace(",", ".");
break;
}
case "clp": case "clp":
case "cop": case "cop":
case "twd": { case "twd":
case "mxn": {
break; break;
} }
default: default:

51
lib/extractPrice.js Normal file
View file

@ -0,0 +1,51 @@
const timeout = (duration) => new Promise((r) => setTimeout(r, duration));
const observePriceOnPage = (page) =>
new Promise((res, rej) => {
timeout(5000).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, {
childList: true,
characterData: true,
subtree: true,
});
});
export 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);
document.body.removeChild(iframe);
return price;
} catch (e) {
l("getPriceForCountry", e, url);
return "";
}
};

View file

@ -1,52 +0,0 @@
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);
const price = await observePriceOnPage(iframe.contentDocument.body, url);
document.body.removeChild(iframe);
return price;
} catch (e) {
l("getPriceForCountry", e, url);
return "";
}
};

View file

@ -1,104 +1,208 @@
import { useSelectedCountries } from "./useCountries";
import "./logger"; import "./logger";
import { COUNTRIES } from "./countries"; import { createElement, h, render } from "preact";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { bookUrl } from "./bookUrl";
import { initCache } from "./cache"; import { initCache } from "./cache";
import { bookPriceFor } from "./bookPriceFor"; import { usePrices } from "./usePrices";
/* /*
TODO: TODO:
- publish to stores
- more durable source for rates - more durable source for rates
- React for UI
- More informative UI, show loading progress
- clear stale cache
- readme how to use and debug - readme how to use and debug
- configuration (purge cache, change base currency)
*/ */
const createPricesContainer = () => { const createPricesContainer = () => {
const pricingActionContainer = document.querySelector( const pricingActionContainers = document.querySelectorAll(".pricing-details");
".primary-right-container",
);
const container = document.createElement("div"); l("all pricing containers", pricingActionContainers);
container.style.display = "flex"; let visible;
container.style.flexDirection = "column"; for (const node of pricingActionContainers) {
container.style.border = "1px solid black"; if (node.checkVisibility()) {
container.style.padding = "10px"; l("found visible pricing container", node);
visible = node;
break;
}
}
return pricingActionContainer.parentNode.insertBefore( return visible.parentNode.insertBefore(
container, document.createElement("div"),
pricingActionContainer, visible,
); );
}; };
const getPrices = async () => { const isInLibrary = () => document.querySelectorAll(".read-now").length;
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 = (countryPrice, convertedPrice, isSelected) => {
if (!isSelected) return "SKIP";
if (countryPrice == undefined) {
return "NOT LOADED";
}
if (convertedPrice) {
return `${countryPrice} => ${convertedPrice?.formatted}`;
}
return "NOT FOUND";
}; };
const sortPrices = (prices) => const Price = ({ price, toggleCountry, isSelected }) => {
prices.sort( const { convertedPrice, countryCode, countryPrice } = price;
(a, b) => const [isHovered, setHover] = useState(false);
(a?.convertedPrice?.intValue || Infinity) -
(b?.convertedPrice?.intValue || Infinity),
);
const showPrices = (container, prices) => { const hover = useCallback(() => setHover(true));
container.innerText = null; const leave = useCallback(() => setHover(false));
prices.forEach((price) => {
const link = document.createElement("a"); const style = {
link.href = price.url; display: "flex",
link.target = "_blank"; flexDirection: "row",
link.style.marginBottom = "5px"; justifyContent: "space-between",
link.style.display = "flex"; alignItems: "center",
link.style.justifyContent = "space-between"; backgroundColor: isHovered ? "#d7d7d7" : "",
const oldPrice = document.createElement("p"); padding: "0 10px",
oldPrice.innerText = `${price.countryCode.toUpperCase()}: ${price?.countryPrice || "NO PRICE"}`; };
const newPrice = document.createElement("p");
newPrice.innerText = price?.convertedPrice?.formatted ?? "NO PRICE"; const link = h(
newPrice.style.fontWeight = "bold"; "a",
link.appendChild(oldPrice); {
link.appendChild(newPrice); style: { textDecoration: "underline" },
container.appendChild(link); href: bookUrl(countryCode),
}); target: "_blank",
},
countryCode.toUpperCase(),
);
const checkbox = h("input", {
type: "checkbox",
checked: isSelected,
onChange: () => toggleCountry(),
});
const priceLabel = h(
"p",
{ style: { fontWeight: "bold" } },
formatPrice(countryPrice, convertedPrice, isSelected),
);
return h(
"label",
{ style, onMouseEnter: hover, onMouseLeave: leave },
h("div", { style: { display: "flex" } }, checkbox, link),
priceLabel,
);
}; };
async function main() { const InLibrary = () =>
const container = createPricesContainer(); h(
"div",
{
style: {
display: "flex",
flexDirection: "column",
border: "1px solid black",
textAlign: "center",
},
},
h("h2", null, "Already in Library!"),
);
container.innerText = "LOADING PRICES..."; const Percent = (percent) => {
const spinner = useRef();
try { useEffect(() => {
initCache(); spinner.current.animate(
[{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],
{ iterations: Infinity, duration: 1000 },
);
}, []);
const countriesPrice = await getPrices(); const style = { padding: "10px 10px 0 10px", textAlign: "center" };
l("country prices", countriesPrice); if (percent == 100) return h("h2", { style }, "all prices are loaded!");
sortPrices(countriesPrice); return h(
"h2",
{ style },
`${percent}%`,
h("span", {
ref: spinner,
style: {
width: "16px",
height: "16px",
borderRadius: "50%",
display: "inline-block",
borderTop: "3px solid black",
borderRight: "3px solid transparent",
boxSizing: "border-box",
marginLeft: "15px",
},
}),
);
};
l("sorted prices", countriesPrice); const Header = (state, percentChecked) => {
if (state === "loading") return Percent(percentChecked);
showPrices(container, countriesPrice); if (state === "done")
} catch (e) { return h(
l("error", e); "h2",
container.innerText = "FAILED TO LOAD PRICES. CHECK CONSOLE FOR MORE INFO"; { style: { textAlign: "center" } },
} finally { "All prices are loaded!",
l("done"); );
}
} return h(
"h2",
{ style: { textAlign: "center" } },
'Select countries or leave as is and press "load"',
);
};
const Load = (state, load) => {
return h(
"button",
{
disabled: state === "loading",
onClick: load,
type: "button",
style: { backgroundColor: "#91ff91" },
},
state === "loading" ? "Loading..." : "Load prices",
);
};
const App = () => {
const { isSelected, toggleCountry, selected } = useSelectedCountries();
const { prices, percentChecked, state, load } = usePrices(selected);
l(selected);
const style = {
display: "flex",
flexDirection: "column",
border: "1px solid black",
};
if (isInLibrary()) {
return InLibrary();
}
return h(
"div",
{ style },
Header(state, percentChecked),
...prices.map((price) =>
h(Price, {
price,
toggleCountry: () => toggleCountry(price.countryCode),
isSelected: isSelected(price.countryCode),
isLoading: state,
}),
),
Load(state, load),
);
};
l("starting..."); l("starting...");
initCache();
window.onload = () => { render(createElement(App), createPricesContainer());
l("page is fully loaded");
void main();
};

22
lib/useCountries.js Normal file
View file

@ -0,0 +1,22 @@
import "./logger";
import { useCallback, useEffect, useState } from "preact/hooks";
import { cacheSelectedCountries, getSelectedCountries } from "./cache";
export const useSelectedCountries = () => {
const [selected, setSelected] = useState(() => getSelectedCountries());
useEffect(() => cacheSelectedCountries(selected), [selected]);
const toggleCountry = useCallback((countryCode) =>
setSelected({ ...selected, [countryCode]: !selected[countryCode] }),
);
const isSelected = useCallback((countryCode) => !!selected[countryCode]);
return {
selected,
toggleCountry,
isSelected,
};
};

61
lib/usePrices.js Normal file
View file

@ -0,0 +1,61 @@
import { useCallback, useState } from "preact/hooks";
import { bookPriceFor } from "./bookPriceFor";
import { COUNTRIES } from "./countries";
const sortPrices = (prices, selectedCountries) =>
prices.sort((a, b) => {
if (!selectedCountries[a.countryCode]) return 1;
if (!selectedCountries[b.countryCode]) return -1;
return (
(a?.convertedPrice?.intValue || Infinity) -
(b?.convertedPrice?.intValue || Infinity)
);
});
export const usePrices = (selectedCountries) => {
const [prices, setPrices] = useState(COUNTRIES);
const [state, setState] = useState("init");
const load = useCallback(async () => {
setState("loading");
for (let index = 0; index < prices.length; index += 2) {
if (!selectedCountries[prices[index].countryCode]) continue;
// intentionally blocking execution
// to resolve sequentially.
// It should prevent DOS and triggering captcha
if (index + 1 < prices.length) {
const [first, second] = await Promise.all([
bookPriceFor(prices[index]),
bookPriceFor(prices[index + 1]),
]);
prices[index] = first;
prices[index + 1] = second;
} else {
prices[index] = await bookPriceFor(prices[index]);
}
setPrices([...prices]);
}
setState("done");
l("DONE");
}, [selectedCountries]);
const selectedCount = Object.entries(selectedCountries)
.map(([_, selected]) => selected)
.filter(Boolean).length;
const alreadyLoaded = prices.filter(
(p) => p.countryPrice != undefined && selectedCountries[p.countryCode],
).length;
const percentChecked = (alreadyLoaded / selectedCount) * 100;
return {
prices: sortPrices(prices, selectedCountries),
percentChecked: percentChecked.toFixed(2),
load,
state,
};
};

View file

@ -1,9 +1,8 @@
{ {
"name": "Kobo Price", "name": "Kobo Price",
"description": "Find lowest book price on kobo.com", "description": "Find lowest book price on kobo.com",
"version": "1.1.1", "version": "1.3.0",
"manifest_version": 3, "manifest_version": 3,
"permissions": ["activeTab"],
"content_scripts": [ "content_scripts": [
{ {
"js": ["dist/index.js"], "js": ["dist/index.js"],
@ -18,5 +17,12 @@
"gecko": { "gecko": {
"id": "koboprice@tertyshny.dev" "id": "koboprice@tertyshny.dev"
} }
},
"icons": {
"16": "icons/16.png",
"32": "icons/32.png",
"48": "icons/48.png",
"128": "icons/128.png"
} }
} }

12
package-lock.json generated
View file

@ -10,7 +10,8 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"currency.js": "^2.0.4", "currency.js": "^2.0.4",
"date-fns": "^3.6.0" "date-fns": "^3.6.0",
"preact": "^10.22.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.6.0", "@eslint/js": "^9.6.0",
@ -4868,6 +4869,15 @@
"integrity": "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==", "integrity": "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==",
"dev": true "dev": true
}, },
"node_modules/preact": {
"version": "10.22.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.22.1.tgz",
"integrity": "sha512-jRYbDDgMpIb5LHq3hkI0bbl+l/TQ9UnkdQ0ww+lp+4MMOdqaUYdFc5qeyP+IV8FAd/2Em7drVPeKdQxsiWCf/A==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",

View file

@ -7,10 +7,13 @@
"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 --outfile=dist/index.js", "bundle": "esbuild lib/index.js --bundle --minify --sourcemap --target=chrome58,firefox57,safari11 --outfile=dist/index.js",
"watch-build": "npm run build -- --watch", "watch-bundle": "npm run bundle -- --watch",
"watch-ext": "web-ext run --start-url kobo.com", "watch-ext": "web-ext run --start-url https://www.kobo.com/ww/en/ebook/foundation-the-foundation-trilogy-book-1-1",
"watch": "concurrently npm:watch-build npm:watch-ext" "watch": "concurrently npm:watch-bundle npm:watch-ext",
"build-userscript": "cat userscript/koboprice.meta.js dist/index.js > userscript/koboprice.user.js",
"build-ext": "web-ext build",
"build-all": "npm run bundle && npm run build-userscript && rm -rf web-ext-artifacts && npm run build-ext"
}, },
"author": "", "author": "",
"license": "ISC", "license": "ISC",
@ -27,6 +30,7 @@
}, },
"dependencies": { "dependencies": {
"currency.js": "^2.0.4", "currency.js": "^2.0.4",
"date-fns": "^3.6.0" "date-fns": "^3.6.0",
"preact": "^10.22.1"
} }
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long