Compare commits
No commits in common. "982800821b2cdfc7ebade9c2ad75619d199abf6a" and "3f1fcac6638a072e969768dc4bc75fceffa0e660" have entirely different histories.
982800821b
...
3f1fcac663
22 changed files with 148 additions and 435 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -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
30
README.md
|
@ -1,30 +0,0 @@
|
||||||
|
|
||||||
# :dollar: Kobo Price
|
|
||||||
|
|
||||||
Helps you find the lowest book price on kobo.com.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 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 [
|
export default [
|
||||||
{
|
{
|
||||||
ignores: ["dist", "userscript"],
|
ignores: ["dist"],
|
||||||
},
|
},
|
||||||
eslintPluginPrettierRecommended,
|
eslintPluginPrettierRecommended,
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
|
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
BIN
icons/16.png
BIN
icons/16.png
Binary file not shown.
Before Width: | Height: | Size: 1.9 KiB |
BIN
icons/32.png
BIN
icons/32.png
Binary file not shown.
Before Width: | Height: | Size: 2.9 KiB |
BIN
icons/48.png
BIN
icons/48.png
Binary file not shown.
Before Width: | Height: | Size: 4.6 KiB |
BIN
img/example.gif
BIN
img/example.gif
Binary file not shown.
Before Width: | Height: | Size: 673 KiB |
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
||||||
};
|
};
|
||||||
|
|
21
lib/cache.js
21
lib/cache.js
|
@ -1,20 +1,8 @@
|
||||||
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({
|
setState({ books: {}, rates: null });
|
||||||
books: {},
|
|
||||||
rates: null,
|
|
||||||
countries: initCountries(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
export const getState = () => JSON.parse(localStorage.getItem(CACHE_PREFIX));
|
export const getState = () => JSON.parse(localStorage.getItem(CACHE_PREFIX));
|
||||||
|
@ -28,10 +16,3 @@ export const cacheBookPrice = (price, url) => {
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -1,51 +0,0 @@
|
||||||
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 "";
|
|
||||||
}
|
|
||||||
};
|
|
52
lib/extractPriceFrom.js
Normal file
52
lib/extractPriceFrom.js
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
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 "";
|
||||||
|
}
|
||||||
|
};
|
242
lib/index.js
242
lib/index.js
|
@ -1,208 +1,104 @@
|
||||||
import { useSelectedCountries } from "./useCountries";
|
|
||||||
import "./logger";
|
import "./logger";
|
||||||
import { createElement, h, render } from "preact";
|
import { COUNTRIES } from "./countries";
|
||||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
|
||||||
import { bookUrl } from "./bookUrl";
|
|
||||||
import { initCache } from "./cache";
|
import { initCache } from "./cache";
|
||||||
import { usePrices } from "./usePrices";
|
import { bookPriceFor } from "./bookPriceFor";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
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, isSelected) => {
|
for (const country of COUNTRIES) {
|
||||||
if (!isSelected) return "SKIP";
|
// intentionally blocking execution
|
||||||
|
// to resolve sequentially.
|
||||||
if (countryPrice == undefined) {
|
// It should prevent DOS and triggering captcha
|
||||||
return "NOT LOADED";
|
prices.push(await bookPriceFor(country));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (convertedPrice) {
|
return prices;
|
||||||
return `${countryPrice} => ${convertedPrice?.formatted}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "NOT FOUND";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Price = ({ price, toggleCountry, isSelected }) => {
|
const sortPrices = (prices) =>
|
||||||
const { convertedPrice, countryCode, countryPrice } = price;
|
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",
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: isHovered ? "#d7d7d7" : "",
|
|
||||||
padding: "0 10px",
|
|
||||||
};
|
|
||||||
|
|
||||||
const link = h(
|
|
||||||
"a",
|
|
||||||
{
|
|
||||||
style: { textDecoration: "underline" },
|
|
||||||
href: bookUrl(countryCode),
|
|
||||||
target: "_blank",
|
|
||||||
},
|
|
||||||
countryCode.toUpperCase(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const checkbox = h("input", {
|
const showPrices = (container, prices) => {
|
||||||
type: "checkbox",
|
container.innerText = null;
|
||||||
checked: isSelected,
|
prices.forEach((price) => {
|
||||||
onChange: () => toggleCountry(),
|
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 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,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const InLibrary = () =>
|
async function main() {
|
||||||
h(
|
const container = createPricesContainer();
|
||||||
"div",
|
|
||||||
{
|
|
||||||
style: {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
border: "1px solid black",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
h("h2", null, "Already in Library!"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const Percent = (percent) => {
|
container.innerText = "LOADING PRICES...";
|
||||||
const spinner = useRef();
|
|
||||||
|
|
||||||
useEffect(() => {
|
try {
|
||||||
spinner.current.animate(
|
initCache();
|
||||||
[{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],
|
|
||||||
{ iterations: Infinity, duration: 1000 },
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const style = { padding: "10px 10px 0 10px", textAlign: "center" };
|
const countriesPrice = await getPrices();
|
||||||
|
|
||||||
if (percent == 100) return h("h2", { style }, "all prices are loaded!");
|
l("country prices", countriesPrice);
|
||||||
|
|
||||||
return h(
|
sortPrices(countriesPrice);
|
||||||
"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",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Header = (state, percentChecked) => {
|
l("sorted prices", countriesPrice);
|
||||||
if (state === "loading") return Percent(percentChecked);
|
|
||||||
|
|
||||||
if (state === "done")
|
showPrices(container, countriesPrice);
|
||||||
return h(
|
} catch (e) {
|
||||||
"h2",
|
l("error", e);
|
||||||
{ style: { textAlign: "center" } },
|
container.innerText = "FAILED TO LOAD PRICES. CHECK CONSOLE FOR MORE INFO";
|
||||||
"All prices are loaded!",
|
} finally {
|
||||||
);
|
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();
|
|
||||||
render(createElement(App), createPricesContainer());
|
window.onload = () => {
|
||||||
|
l("page is fully loaded");
|
||||||
|
void main();
|
||||||
|
};
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,61 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,8 +1,9 @@
|
||||||
{
|
{
|
||||||
"name": "Kobo Price",
|
"name": "Kobo Price",
|
||||||
"description": "Find lowest book price on kobo.com",
|
"description": "Find lowest book price on kobo.com",
|
||||||
"version": "1.3.0",
|
"version": "1.1.1",
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
|
"permissions": ["activeTab"],
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"js": ["dist/index.js"],
|
"js": ["dist/index.js"],
|
||||||
|
@ -17,12 +18,5 @@
|
||||||
"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
12
package-lock.json
generated
|
@ -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",
|
||||||
|
|
14
package.json
14
package.json
|
@ -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 && rm -rf web-ext-artifacts && 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
Loading…
Add table
Reference in a new issue