Compare commits

..

No commits in common. "cbdb829c67017c1e231239d2fe2aed47042f9a47" and "37c3b88bb133e991b54b9595523ad1741f329c61" have entirely different histories.

19 changed files with 96 additions and 249 deletions

4
.gitignore vendored
View file

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

View file

@ -1,30 +0,0 @@
# :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", "userscript"], ignores: ["dist"],
}, },
eslintPluginPrettierRecommended, eslintPluginPrettierRecommended,
js.configs.recommended, js.configs.recommended,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 673 KiB

View file

@ -1,5 +1,5 @@
import { bookUrl } from "./bookUrl"; import { bookUrl } from "./bookUrl";
import { extractPrice } from "./extractPrice"; import { extractPriceFrom } from "./extractPriceFrom";
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 extractPrice(url); const countryPrice = await extractPriceFrom(url);
let convertedPrice; let convertedPrice;

View file

@ -1,9 +1,6 @@
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);
l("url for country", country, url); return window.location.href.replace(urlPattern, newPath);
return url;
}; };

View file

@ -26,14 +26,9 @@ 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:

View file

@ -2,12 +2,12 @@ const timeout = (duration) => new Promise((r) => setTimeout(r, duration));
const observePriceOnPage = (page) => const observePriceOnPage = (page) =>
new Promise((res, rej) => { new Promise((res, rej) => {
timeout(5000).then(() => rej("price not found")); timeout(10000).then(() => rej("price not found"));
var observer = new MutationObserver(() => { var observer = new MutationObserver(() => {
const price = page?.querySelector( const price = page.querySelector(
".primary-right-container .pricing-details .active-price span", ".primary-right-container .pricing-details .active-price span",
)?.textContent; ).textContent;
if (price) { if (price) {
l("found price", price); l("found price", price);
@ -24,7 +24,7 @@ const observePriceOnPage = (page) =>
}); });
}); });
export const extractPrice = async (url) => { export const extractPriceFrom = async (url) => {
try { try {
l("going to", url); l("going to", url);

View file

@ -1,124 +1,104 @@
import "./logger"; import "./logger";
import { COUNTRIES } from "./countries";
import { initCache } from "./cache"; import { initCache } from "./cache";
import { createElement, h, render } from "preact"; import { bookPriceFor } from "./bookPriceFor";
import { usePrices } from "./usePrices";
import { useCallback, useState } from "preact/hooks";
/* /*
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 pricingActionContainers = document.querySelectorAll(".pricing-details"); const pricingActionContainer = document.querySelector(
".primary-right-container",
);
l("all pricing containers", pricingActionContainers); const container = document.createElement("div");
let visible; container.style.display = "flex";
for (const node of pricingActionContainers) { container.style.flexDirection = "column";
if (node.checkVisibility()) { container.style.border = "1px solid black";
l("found visible pricing container", node); container.style.padding = "10px";
visible = node;
break;
}
}
return visible.parentNode.insertBefore( return pricingActionContainer.parentNode.insertBefore(
document.createElement("div"), container,
visible, pricingActionContainer,
); );
}; };
const isInLibrary = () => document.querySelectorAll(".read-now").length; const getPrices = async () => {
const prices = [];
const formatPrice = (countryPrice, convertedPrice) => { for (const country of COUNTRIES) {
if (countryPrice == undefined) { // intentionally blocking execution
return `LOADING`; // to resolve sequentially.
// It should prevent DOS and triggering captcha
prices.push(await bookPriceFor(country));
} }
if (convertedPrice) { return prices;
return `${countryPrice} => ${convertedPrice?.formatted}`;
}
return `NOT FOUND`;
}; };
const Price = ({ props }) => { const sortPrices = (prices) =>
const { convertedPrice, countryCode, countryPrice, url } = props; prices.sort(
const [isHovered, setHover] = useState(false); (a, b) =>
(a?.convertedPrice?.intValue || Infinity) -
const hover = useCallback(() => setHover(true)); (b?.convertedPrice?.intValue || Infinity),
const leave = useCallback(() => setHover(false));
const style = {
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
backgroundColor: isHovered ? "#d7d7d7" : "",
padding: "10px 10px 0 10px",
};
return h(
"a",
{
style,
href: url,
target: "_blank",
onMouseEnter: hover,
onMouseLeave: leave,
},
[
h("p", null, countryCode.toUpperCase()),
h(
"p",
{ style: { fontWeight: "bold" } },
formatPrice(countryPrice, convertedPrice),
),
],
);
};
const InLibrary = () =>
h(
"div",
{
style: {
display: "flex",
flexDirection: "column",
border: "1px solid black",
textAlign: "center",
},
},
h("h2", null, "Already in Library!"),
); );
const Percent = (percent) => { const showPrices = (container, prices) => {
const style = { padding: "10px 10px 0 10px", textAlign: "center" }; container.innerText = null;
prices.forEach((price) => {
if (percent == 100) return h("h2", { style }, "all prices are loaded!"); const link = document.createElement("a");
link.href = price.url;
return h("h2", { style }, `${percent}%`); 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 App = () => { async function main() {
const [prices, percentChecked] = usePrices(); const container = createPricesContainer();
const style = { container.innerText = "LOADING PRICES...";
display: "flex",
flexDirection: "column",
border: "1px solid black",
};
if (isInLibrary()) { try {
return InLibrary(); 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");
}
} }
return h("div", { style }, [
Percent(percentChecked),
...prices.map((price) => h(Price, { props: price })),
]);
};
l("starting..."); l("starting...");
initCache();
render(createElement(App), createPricesContainer()); window.onload = () => {
l("page is fully loaded");
void main();
};

View file

@ -1,44 +0,0 @@
import { useEffect, useState } from "preact/hooks";
import { COUNTRIES } from "./countries";
import { bookPriceFor } from "./bookPriceFor";
const sortPrices = (prices) =>
prices.sort(
(a, b) =>
(a?.convertedPrice?.intValue || Infinity) -
(b?.convertedPrice?.intValue || Infinity),
);
export const usePrices = () => {
const [prices, setPrices] = useState(COUNTRIES);
useEffect(() => {
const fetchAll = async () => {
for (let index = 0; index < prices.length; index += 2) {
// 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]);
}
sortPrices(prices);
setPrices([...prices]);
}
l("DONE");
};
fetchAll();
}, []);
const percentChecked =
(prices.filter((p) => p.countryPrice != undefined).length / prices.length) *
100;
return [prices, percentChecked.toFixed(0)];
};

View file

@ -1,8 +1,9 @@
{ {
"name": "Kobo Price", "name": "Kobo Price",
"description": "Find lowest book price on kobo.com", "description": "Lowest book price finder",
"version": "1.2.3", "version": "1.0",
"manifest_version": 3, "manifest_version": 3,
"permissions": ["activeTab"],
"content_scripts": [ "content_scripts": [
{ {
"js": ["dist/index.js"], "js": ["dist/index.js"],
@ -12,17 +13,5 @@
"https://www.kobo.com/*/*/audiobook/*" "https://www.kobo.com/*/*/audiobook/*"
] ]
} }
], ]
"browser_specific_settings": {
"gecko": {
"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,8 +10,7 @@
"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",
@ -4869,15 +4868,6 @@
"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,13 +7,10 @@
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint --fix", "lint": "eslint --fix",
"bundle": "esbuild lib/index.js --bundle --minify --sourcemap --target=chrome58,firefox57,safari11 --outfile=dist/index.js", "build": "esbuild lib/index.js --bundle --minify --sourcemap --target=chrome58,firefox57,safari11 --outfile=dist/index.js",
"watch-bundle": "npm run bundle -- --watch", "watch-build": "npm run build -- --watch",
"watch-ext": "web-ext run --start-url https://www.kobo.com/ww/en/ebook/foundation-the-foundation-trilogy-book-1-1", "watch-ext": "web-ext run --start-url kobo.com",
"watch": "concurrently npm:watch-bundle npm:watch-ext", "watch": "concurrently npm:watch-build 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 && npm run build-ext"
}, },
"author": "", "author": "",
"license": "ISC", "license": "ISC",
@ -30,7 +27,6 @@
}, },
"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