Compare commits
10 commits
37c3b88bb1
...
cbdb829c67
Author | SHA1 | Date | |
---|---|---|---|
cbdb829c67 | |||
7380c65eff | |||
eb46aaa3b5 | |||
d7676019d0 | |||
94b688843c | |||
3c45019850 | |||
d0e7e359e1 | |||
37f030f671 | |||
fec251aea9 | |||
3f1fcac663 |
19 changed files with 253 additions and 100 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -1,3 +1,5 @@
|
|||
node_modules
|
||||
dist
|
||||
.vscode
|
||||
.vscode
|
||||
web-ext-artifacts
|
||||
.amo-upload-uuid
|
||||
dist
|
30
README.md
Normal file
30
README.md
Normal 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
|
||||
```
|
|
@ -4,7 +4,7 @@ import globals from "globals";
|
|||
|
||||
export default [
|
||||
{
|
||||
ignores: ["dist"],
|
||||
ignores: ["dist", "userscript"],
|
||||
},
|
||||
eslintPluginPrettierRecommended,
|
||||
js.configs.recommended,
|
||||
|
|
BIN
icons/128.png
Normal file
BIN
icons/128.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
BIN
icons/16.png
Normal file
BIN
icons/16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
BIN
icons/32.png
Normal file
BIN
icons/32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.9 KiB |
BIN
icons/48.png
Normal file
BIN
icons/48.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.6 KiB |
BIN
img/example.gif
Normal file
BIN
img/example.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 673 KiB |
|
@ -1,5 +1,5 @@
|
|||
import { bookUrl } from "./bookUrl";
|
||||
import { extractPriceFrom } from "./extractPriceFrom";
|
||||
import { extractPrice } from "./extractPrice";
|
||||
import { getRate } from "./rates";
|
||||
import { convertPrice } from "./convertPrice";
|
||||
import { cacheBookPrice, getBookPrice } from "./cache";
|
||||
|
@ -17,7 +17,7 @@ export const bookPriceFor = async (country) => {
|
|||
return fromCache;
|
||||
}
|
||||
|
||||
const countryPrice = await extractPriceFrom(url);
|
||||
const countryPrice = await extractPrice(url);
|
||||
|
||||
let convertedPrice;
|
||||
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
export const bookUrl = (country) => {
|
||||
const urlPattern = /^https:\/\/www\.kobo\.com\/../;
|
||||
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;
|
||||
};
|
||||
|
|
|
@ -26,9 +26,14 @@ export const convertPrice = async (price, curr, rate) => {
|
|||
price = price.replace("S/.", "");
|
||||
break;
|
||||
}
|
||||
case "try": {
|
||||
price = price.replace(".", "").replace(",", ".");
|
||||
break;
|
||||
}
|
||||
case "clp":
|
||||
case "cop":
|
||||
case "twd": {
|
||||
case "twd":
|
||||
case "mxn": {
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
|
|
@ -2,12 +2,12 @@ const timeout = (duration) => new Promise((r) => setTimeout(r, duration));
|
|||
|
||||
const observePriceOnPage = (page) =>
|
||||
new Promise((res, rej) => {
|
||||
timeout(10000).then(() => rej("price not found"));
|
||||
timeout(5000).then(() => rej("price not found"));
|
||||
|
||||
var observer = new MutationObserver(() => {
|
||||
const price = page.querySelector(
|
||||
const price = page?.querySelector(
|
||||
".primary-right-container .pricing-details .active-price span",
|
||||
).textContent;
|
||||
)?.textContent;
|
||||
|
||||
if (price) {
|
||||
l("found price", price);
|
||||
|
@ -24,7 +24,7 @@ const observePriceOnPage = (page) =>
|
|||
});
|
||||
});
|
||||
|
||||
export const extractPriceFrom = async (url) => {
|
||||
export const extractPrice = async (url) => {
|
||||
try {
|
||||
l("going to", url);
|
||||
|
178
lib/index.js
178
lib/index.js
|
@ -1,104 +1,124 @@
|
|||
import "./logger";
|
||||
import { COUNTRIES } from "./countries";
|
||||
import { initCache } from "./cache";
|
||||
import { bookPriceFor } from "./bookPriceFor";
|
||||
import { createElement, h, render } from "preact";
|
||||
import { usePrices } from "./usePrices";
|
||||
import { useCallback, useState } from "preact/hooks";
|
||||
|
||||
/*
|
||||
TODO:
|
||||
- publish to stores
|
||||
- more durable source for rates
|
||||
- React for UI
|
||||
- More informative UI, show loading progress
|
||||
- clear stale cache
|
||||
- readme how to use and debug
|
||||
- configuration (purge cache, change base currency)
|
||||
*/
|
||||
|
||||
const createPricesContainer = () => {
|
||||
const pricingActionContainer = document.querySelector(
|
||||
".primary-right-container",
|
||||
);
|
||||
const pricingActionContainers = document.querySelectorAll(".pricing-details");
|
||||
|
||||
const container = document.createElement("div");
|
||||
l("all pricing containers", pricingActionContainers);
|
||||
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
container.style.border = "1px solid black";
|
||||
container.style.padding = "10px";
|
||||
|
||||
return pricingActionContainer.parentNode.insertBefore(
|
||||
container,
|
||||
pricingActionContainer,
|
||||
);
|
||||
};
|
||||
|
||||
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));
|
||||
let visible;
|
||||
for (const node of pricingActionContainers) {
|
||||
if (node.checkVisibility()) {
|
||||
l("found visible pricing container", node);
|
||||
visible = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return prices;
|
||||
return visible.parentNode.insertBefore(
|
||||
document.createElement("div"),
|
||||
visible,
|
||||
);
|
||||
};
|
||||
|
||||
const sortPrices = (prices) =>
|
||||
prices.sort(
|
||||
(a, b) =>
|
||||
(a?.convertedPrice?.intValue || Infinity) -
|
||||
(b?.convertedPrice?.intValue || Infinity),
|
||||
const isInLibrary = () => document.querySelectorAll(".read-now").length;
|
||||
|
||||
const formatPrice = (countryPrice, convertedPrice) => {
|
||||
if (countryPrice == undefined) {
|
||||
return `LOADING`;
|
||||
}
|
||||
|
||||
if (convertedPrice) {
|
||||
return `${countryPrice} => ${convertedPrice?.formatted}`;
|
||||
}
|
||||
return `NOT FOUND`;
|
||||
};
|
||||
|
||||
const Price = ({ props }) => {
|
||||
const { convertedPrice, countryCode, countryPrice, url } = props;
|
||||
const [isHovered, setHover] = useState(false);
|
||||
|
||||
const hover = useCallback(() => setHover(true));
|
||||
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 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 Percent = (percent) => {
|
||||
const style = { padding: "10px 10px 0 10px", textAlign: "center" };
|
||||
|
||||
if (percent == 100) return h("h2", { style }, "all prices are loaded!");
|
||||
|
||||
return h("h2", { style }, `${percent}%`);
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const container = createPricesContainer();
|
||||
const App = () => {
|
||||
const [prices, percentChecked] = usePrices();
|
||||
|
||||
container.innerText = "LOADING PRICES...";
|
||||
const style = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
border: "1px solid black",
|
||||
};
|
||||
|
||||
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");
|
||||
if (isInLibrary()) {
|
||||
return InLibrary();
|
||||
}
|
||||
}
|
||||
|
||||
return h("div", { style }, [
|
||||
Percent(percentChecked),
|
||||
...prices.map((price) => h(Price, { props: price })),
|
||||
]);
|
||||
};
|
||||
|
||||
l("starting...");
|
||||
|
||||
window.onload = () => {
|
||||
l("page is fully loaded");
|
||||
void main();
|
||||
};
|
||||
initCache();
|
||||
render(createElement(App), createPricesContainer());
|
||||
|
|
44
lib/usePrices.js
Normal file
44
lib/usePrices.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
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)];
|
||||
};
|
|
@ -1,9 +1,8 @@
|
|||
{
|
||||
"name": "Kobo Price",
|
||||
"description": "Lowest book price finder",
|
||||
"version": "1.0",
|
||||
"description": "Find lowest book price on kobo.com",
|
||||
"version": "1.2.3",
|
||||
"manifest_version": 3,
|
||||
"permissions": ["activeTab"],
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": ["dist/index.js"],
|
||||
|
@ -13,5 +12,17 @@
|
|||
"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
12
package-lock.json
generated
|
@ -10,7 +10,8 @@
|
|||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"currency.js": "^2.0.4",
|
||||
"date-fns": "^3.6.0"
|
||||
"date-fns": "^3.6.0",
|
||||
"preact": "^10.22.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.6.0",
|
||||
|
@ -4868,6 +4869,15 @@
|
|||
"integrity": "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==",
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
|
14
package.json
14
package.json
|
@ -7,10 +7,13 @@
|
|||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"lint": "eslint --fix",
|
||||
"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 --start-url kobo.com",
|
||||
"watch": "concurrently npm:watch-build npm:watch-ext"
|
||||
"bundle": "esbuild lib/index.js --bundle --minify --sourcemap --target=chrome58,firefox57,safari11 --outfile=dist/index.js",
|
||||
"watch-bundle": "npm run bundle -- --watch",
|
||||
"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-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 && npm run build-ext"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
|
@ -27,6 +30,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"currency.js": "^2.0.4",
|
||||
"date-fns": "^3.6.0"
|
||||
"date-fns": "^3.6.0",
|
||||
"preact": "^10.22.1"
|
||||
}
|
||||
}
|
||||
|
|
11
userscript/koboprice.meta.js
Normal file
11
userscript/koboprice.meta.js
Normal file
File diff suppressed because one or more lines are too long
13
userscript/koboprice.user.js
Normal file
13
userscript/koboprice.user.js
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue