select which country to load

This commit is contained in:
Greg 2025-06-11 00:24:03 +03:00
parent cbdb829c67
commit 982800821b
9 changed files with 306 additions and 165 deletions

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

@ -1,52 +1,51 @@
const timeout = (duration) => new Promise((r) => setTimeout(r, duration)); 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(5000).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);
observer.disconnect(); observer.disconnect();
res(price); res(price);
} }
}); });
observer.observe(page, { observer.observe(page, {
attributes: true, childList: true,
childList: true, characterData: true,
characterData: true, subtree: true,
subtree: true, });
}); });
});
export const extractPrice = async (url) => { export const extractPrice = async (url) => {
try { try {
l("going to", url); l("going to", url);
const iframe = document.createElement("iframe"); const iframe = document.createElement("iframe");
iframe.src = url; iframe.src = url;
iframe.hidden = true; iframe.hidden = true;
document.body.append(iframe); document.body.append(iframe);
await new Promise((res) => (iframe.contentWindow.onload = res)); await new Promise((res) => (iframe.contentWindow.onload = res));
l("starting observing price on", url); l("starting observing price on", url);
const price = await observePriceOnPage(iframe.contentDocument.body, url); const price = await observePriceOnPage(iframe.contentDocument.body);
document.body.removeChild(iframe); document.body.removeChild(iframe);
return price; return price;
} catch (e) { } catch (e) {
l("getPriceForCountry", e, url); l("getPriceForCountry", e, url);
return ""; return "";
} }
}; };

View file

@ -1,8 +1,10 @@
import { useSelectedCountries } from "./useCountries";
import "./logger"; import "./logger";
import { initCache } from "./cache";
import { createElement, h, render } from "preact"; import { createElement, h, render } from "preact";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { bookUrl } from "./bookUrl";
import { initCache } from "./cache";
import { usePrices } from "./usePrices"; import { usePrices } from "./usePrices";
import { useCallback, useState } from "preact/hooks";
/* /*
TODO: TODO:
@ -11,112 +13,194 @@ TODO:
*/ */
const createPricesContainer = () => { const createPricesContainer = () => {
const pricingActionContainers = document.querySelectorAll(".pricing-details"); const pricingActionContainers = document.querySelectorAll(".pricing-details");
l("all pricing containers", pricingActionContainers); l("all pricing containers", pricingActionContainers);
let visible; let visible;
for (const node of pricingActionContainers) { for (const node of pricingActionContainers) {
if (node.checkVisibility()) { if (node.checkVisibility()) {
l("found visible pricing container", node); l("found visible pricing container", node);
visible = node; visible = node;
break; break;
} }
} }
return visible.parentNode.insertBefore( return visible.parentNode.insertBefore(
document.createElement("div"), document.createElement("div"),
visible, visible,
); );
}; };
const isInLibrary = () => document.querySelectorAll(".read-now").length; const isInLibrary = () => document.querySelectorAll(".read-now").length;
const formatPrice = (countryPrice, convertedPrice) => { const formatPrice = (countryPrice, convertedPrice, isSelected) => {
if (countryPrice == undefined) { if (!isSelected) return "SKIP";
return `LOADING`;
}
if (convertedPrice) { if (countryPrice == undefined) {
return `${countryPrice} => ${convertedPrice?.formatted}`; return "NOT LOADED";
} }
return `NOT FOUND`;
if (convertedPrice) {
return `${countryPrice} => ${convertedPrice?.formatted}`;
}
return "NOT FOUND";
}; };
const Price = ({ props }) => { const Price = ({ price, toggleCountry, isSelected }) => {
const { convertedPrice, countryCode, countryPrice, url } = props; const { convertedPrice, countryCode, countryPrice } = price;
const [isHovered, setHover] = useState(false); const [isHovered, setHover] = useState(false);
const hover = useCallback(() => setHover(true)); const hover = useCallback(() => setHover(true));
const leave = useCallback(() => setHover(false)); const leave = useCallback(() => setHover(false));
const style = { const style = {
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
backgroundColor: isHovered ? "#d7d7d7" : "", alignItems: "center",
padding: "10px 10px 0 10px", backgroundColor: isHovered ? "#d7d7d7" : "",
}; padding: "0 10px",
};
return h( const link = h(
"a", "a",
{ {
style, style: { textDecoration: "underline" },
href: url, href: bookUrl(countryCode),
target: "_blank", target: "_blank",
onMouseEnter: hover, },
onMouseLeave: leave, countryCode.toUpperCase(),
}, );
[
h("p", null, countryCode.toUpperCase()), const checkbox = h("input", {
h( type: "checkbox",
"p", checked: isSelected,
{ style: { fontWeight: "bold" } }, onChange: () => toggleCountry(),
formatPrice(countryPrice, convertedPrice), });
),
], 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 = () => const InLibrary = () =>
h( h(
"div", "div",
{ {
style: { style: {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
border: "1px solid black", border: "1px solid black",
textAlign: "center", textAlign: "center",
}, },
}, },
h("h2", null, "Already in Library!"), h("h2", null, "Already in Library!"),
); );
const Percent = (percent) => { const Percent = (percent) => {
const style = { padding: "10px 10px 0 10px", textAlign: "center" }; const spinner = useRef();
if (percent == 100) return h("h2", { style }, "all prices are loaded!"); useEffect(() => {
spinner.current.animate(
[{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],
{ iterations: Infinity, duration: 1000 },
);
}, []);
return h("h2", { style }, `${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}%`,
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) => {
if (state === "loading") return Percent(percentChecked);
if (state === "done")
return h(
"h2",
{ style: { textAlign: "center" } },
"All prices are loaded!",
);
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 App = () => {
const [prices, percentChecked] = usePrices(); const { isSelected, toggleCountry, selected } = useSelectedCountries();
const { prices, percentChecked, state, load } = usePrices(selected);
const style = { l(selected);
display: "flex", const style = {
flexDirection: "column", display: "flex",
border: "1px solid black", flexDirection: "column",
}; border: "1px solid black",
};
if (isInLibrary()) { if (isInLibrary()) {
return InLibrary(); return InLibrary();
} }
return h("div", { style }, [ return h(
Percent(percentChecked), "div",
...prices.map((price) => h(Price, { props: price })), { 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...");

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,
};
};

View file

@ -1,44 +1,61 @@
import { useEffect, useState } from "preact/hooks"; import { useCallback, useState } from "preact/hooks";
import { COUNTRIES } from "./countries";
import { bookPriceFor } from "./bookPriceFor"; import { bookPriceFor } from "./bookPriceFor";
import { COUNTRIES } from "./countries";
const sortPrices = (prices) => const sortPrices = (prices, selectedCountries) =>
prices.sort( prices.sort((a, b) => {
(a, b) => if (!selectedCountries[a.countryCode]) return 1;
(a?.convertedPrice?.intValue || Infinity) - if (!selectedCountries[b.countryCode]) return -1;
(b?.convertedPrice?.intValue || Infinity),
);
export const usePrices = () => { return (
const [prices, setPrices] = useState(COUNTRIES); (a?.convertedPrice?.intValue || Infinity) -
useEffect(() => { (b?.convertedPrice?.intValue || Infinity)
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); export const usePrices = (selectedCountries) => {
setPrices([...prices]); const [prices, setPrices] = useState(COUNTRIES);
} const [state, setState] = useState("init");
l("DONE");
};
fetchAll();
}, []);
const percentChecked = const load = useCallback(async () => {
(prices.filter((p) => p.countryPrice != undefined).length / prices.length) * setState("loading");
100; for (let index = 0; index < prices.length; index += 2) {
if (!selectedCountries[prices[index].countryCode]) continue;
return [prices, percentChecked.toFixed(0)]; // 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,7 +1,7 @@
{ {
"name": "Kobo Price", "name": "Kobo Price",
"description": "Find lowest book price on kobo.com", "description": "Find lowest book price on kobo.com",
"version": "1.2.3", "version": "1.3.0",
"manifest_version": 3, "manifest_version": 3,
"content_scripts": [ "content_scripts": [
{ {

View file

@ -13,7 +13,7 @@
"watch": "concurrently npm:watch-bundle 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-userscript": "cat userscript/koboprice.meta.js dist/index.js > userscript/koboprice.user.js",
"build-ext": "web-ext build", "build-ext": "web-ext build",
"build-all": "npm run bundle && npm run build-userscript && npm run build-ext" "build-all": "npm run bundle && npm run build-userscript && rm -rf web-ext-artifacts && npm run build-ext"
}, },
"author": "", "author": "",
"license": "ISC", "license": "ISC",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long