select which country to load
This commit is contained in:
parent
cbdb829c67
commit
982800821b
9 changed files with 306 additions and 165 deletions
33
lib/cache.js
33
lib/cache.js
|
@ -1,18 +1,37 @@
|
|||
import { COUNTRIES } from "./countries";
|
||||
|
||||
const CACHE_PREFIX = "KOBOPRICE";
|
||||
|
||||
const initCountries = () =>
|
||||
COUNTRIES.reduce(
|
||||
(acc, { countryCode }) => ({ ...acc, [countryCode]: true }),
|
||||
{},
|
||||
);
|
||||
|
||||
export const initCache = () => {
|
||||
if (!localStorage.getItem(CACHE_PREFIX)) {
|
||||
setState({ books: {}, rates: null });
|
||||
}
|
||||
if (!localStorage.getItem(CACHE_PREFIX)) {
|
||||
setState({
|
||||
books: {},
|
||||
rates: null,
|
||||
countries: initCountries(),
|
||||
});
|
||||
}
|
||||
};
|
||||
export const getState = () => JSON.parse(localStorage.getItem(CACHE_PREFIX));
|
||||
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 cacheRates = (rates) => setState({ ...getState(), rates });
|
||||
export const getBookPrice = (url) => getState().books[url];
|
||||
export const cacheBookPrice = (price, url) => {
|
||||
const state = getState();
|
||||
state.books[url] = price;
|
||||
setState(state);
|
||||
const state = getState();
|
||||
state.books[url] = price;
|
||||
setState(state);
|
||||
};
|
||||
export const getSelectedCountries = () =>
|
||||
getState().countries || initCountries();
|
||||
export const cacheSelectedCountries = (countries) => {
|
||||
const state = getState();
|
||||
state.countries = countries;
|
||||
setState(state);
|
||||
};
|
||||
|
|
|
@ -1,52 +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"));
|
||||
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;
|
||||
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);
|
||||
}
|
||||
});
|
||||
if (price) {
|
||||
l("found price", price);
|
||||
observer.disconnect();
|
||||
res(price);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(page, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
});
|
||||
});
|
||||
observer.observe(page, {
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
});
|
||||
});
|
||||
|
||||
export const extractPrice = async (url) => {
|
||||
try {
|
||||
l("going to", url);
|
||||
try {
|
||||
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;
|
||||
} catch (e) {
|
||||
l("getPriceForCountry", e, url);
|
||||
return "";
|
||||
}
|
||||
return price;
|
||||
} catch (e) {
|
||||
l("getPriceForCountry", e, url);
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
|
248
lib/index.js
248
lib/index.js
|
@ -1,8 +1,10 @@
|
|||
import { useSelectedCountries } from "./useCountries";
|
||||
import "./logger";
|
||||
import { initCache } from "./cache";
|
||||
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 { useCallback, useState } from "preact/hooks";
|
||||
|
||||
/*
|
||||
TODO:
|
||||
|
@ -11,112 +13,194 @@ TODO:
|
|||
*/
|
||||
|
||||
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;
|
||||
for (const node of pricingActionContainers) {
|
||||
if (node.checkVisibility()) {
|
||||
l("found visible pricing container", node);
|
||||
visible = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let visible;
|
||||
for (const node of pricingActionContainers) {
|
||||
if (node.checkVisibility()) {
|
||||
l("found visible pricing container", node);
|
||||
visible = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return visible.parentNode.insertBefore(
|
||||
document.createElement("div"),
|
||||
visible,
|
||||
);
|
||||
return visible.parentNode.insertBefore(
|
||||
document.createElement("div"),
|
||||
visible,
|
||||
);
|
||||
};
|
||||
|
||||
const isInLibrary = () => document.querySelectorAll(".read-now").length;
|
||||
|
||||
const formatPrice = (countryPrice, convertedPrice) => {
|
||||
if (countryPrice == undefined) {
|
||||
return `LOADING`;
|
||||
}
|
||||
const formatPrice = (countryPrice, convertedPrice, isSelected) => {
|
||||
if (!isSelected) return "SKIP";
|
||||
|
||||
if (convertedPrice) {
|
||||
return `${countryPrice} => ${convertedPrice?.formatted}`;
|
||||
}
|
||||
return `NOT FOUND`;
|
||||
if (countryPrice == undefined) {
|
||||
return "NOT LOADED";
|
||||
}
|
||||
|
||||
if (convertedPrice) {
|
||||
return `${countryPrice} => ${convertedPrice?.formatted}`;
|
||||
}
|
||||
|
||||
return "NOT FOUND";
|
||||
};
|
||||
|
||||
const Price = ({ props }) => {
|
||||
const { convertedPrice, countryCode, countryPrice, url } = props;
|
||||
const [isHovered, setHover] = useState(false);
|
||||
const Price = ({ price, toggleCountry, isSelected }) => {
|
||||
const { convertedPrice, countryCode, countryPrice } = price;
|
||||
const [isHovered, setHover] = useState(false);
|
||||
|
||||
const hover = useCallback(() => setHover(true));
|
||||
const leave = useCallback(() => setHover(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",
|
||||
};
|
||||
const style = {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
backgroundColor: isHovered ? "#d7d7d7" : "",
|
||||
padding: "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 link = h(
|
||||
"a",
|
||||
{
|
||||
style: { textDecoration: "underline" },
|
||||
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,
|
||||
);
|
||||
};
|
||||
|
||||
const InLibrary = () =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
border: "1px solid black",
|
||||
textAlign: "center",
|
||||
},
|
||||
},
|
||||
h("h2", null, "Already in Library!"),
|
||||
);
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
border: "1px solid black",
|
||||
textAlign: "center",
|
||||
},
|
||||
},
|
||||
h("h2", null, "Already in Library!"),
|
||||
);
|
||||
|
||||
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 [prices, percentChecked] = usePrices();
|
||||
const { isSelected, toggleCountry, selected } = useSelectedCountries();
|
||||
const { prices, percentChecked, state, load } = usePrices(selected);
|
||||
|
||||
const style = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
border: "1px solid black",
|
||||
};
|
||||
l(selected);
|
||||
const style = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
border: "1px solid black",
|
||||
};
|
||||
|
||||
if (isInLibrary()) {
|
||||
return InLibrary();
|
||||
}
|
||||
if (isInLibrary()) {
|
||||
return InLibrary();
|
||||
}
|
||||
|
||||
return h("div", { style }, [
|
||||
Percent(percentChecked),
|
||||
...prices.map((price) => h(Price, { props: price })),
|
||||
]);
|
||||
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...");
|
||||
|
|
22
lib/useCountries.js
Normal file
22
lib/useCountries.js
Normal 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,
|
||||
};
|
||||
};
|
|
@ -1,44 +1,61 @@
|
|||
import { useEffect, useState } from "preact/hooks";
|
||||
import { COUNTRIES } from "./countries";
|
||||
import { useCallback, useState } from "preact/hooks";
|
||||
import { bookPriceFor } from "./bookPriceFor";
|
||||
import { COUNTRIES } from "./countries";
|
||||
|
||||
const sortPrices = (prices) =>
|
||||
prices.sort(
|
||||
(a, b) =>
|
||||
(a?.convertedPrice?.intValue || Infinity) -
|
||||
(b?.convertedPrice?.intValue || Infinity),
|
||||
);
|
||||
const sortPrices = (prices, selectedCountries) =>
|
||||
prices.sort((a, b) => {
|
||||
if (!selectedCountries[a.countryCode]) return 1;
|
||||
if (!selectedCountries[b.countryCode]) return -1;
|
||||
|
||||
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]);
|
||||
}
|
||||
return (
|
||||
(a?.convertedPrice?.intValue || Infinity) -
|
||||
(b?.convertedPrice?.intValue || Infinity)
|
||||
);
|
||||
});
|
||||
|
||||
sortPrices(prices);
|
||||
setPrices([...prices]);
|
||||
}
|
||||
l("DONE");
|
||||
};
|
||||
fetchAll();
|
||||
}, []);
|
||||
export const usePrices = (selectedCountries) => {
|
||||
const [prices, setPrices] = useState(COUNTRIES);
|
||||
const [state, setState] = useState("init");
|
||||
|
||||
const percentChecked =
|
||||
(prices.filter((p) => p.countryPrice != undefined).length / prices.length) *
|
||||
100;
|
||||
const load = useCallback(async () => {
|
||||
setState("loading");
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "Kobo Price",
|
||||
"description": "Find lowest book price on kobo.com",
|
||||
"version": "1.2.3",
|
||||
"version": "1.3.0",
|
||||
"manifest_version": 3,
|
||||
"content_scripts": [
|
||||
{
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
"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"
|
||||
"build-all": "npm run bundle && npm run build-userscript && rm -rf web-ext-artifacts && npm run build-ext"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
|
|
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