Compare commits

..

5 commits

Author SHA1 Message Date
c916ab1793 new userscript 2026-07-04 23:24:17 +03:00
39cc31eeca move checkbox 2026-07-04 23:23:30 +03:00
78c08be6b8 readable countries 2026-07-04 23:21:18 +03:00
6d61a34503 inject container to the root of a page 2026-07-04 23:09:52 +03:00
60930c4f1a extract prices from new website 2026-07-04 23:08:10 +03:00
4 changed files with 273 additions and 205 deletions

View file

@ -1,49 +1,49 @@
export const COUNTRIES = [ export const COUNTRIES = [
{ countryCode: "ww", currencyCode: "usd" }, { countryCode: "ww", countryName: "Worldwide", currencyCode: "usd" },
{ countryCode: "ca", currencyCode: "cad" }, { countryCode: "ca", countryName: "Canada", currencyCode: "cad" },
{ countryCode: "us", currencyCode: "usd" }, { countryCode: "us", countryName: "United States", currencyCode: "usd" },
{ countryCode: "in", currencyCode: "inr" }, { countryCode: "in", countryName: "India", currencyCode: "inr" },
{ countryCode: "za", currencyCode: "zar" }, { countryCode: "za", countryName: "South Africa", currencyCode: "zar" },
{ countryCode: "au", currencyCode: "aud" }, { countryCode: "au", countryName: "Australia", currencyCode: "aud" },
{ countryCode: "hk", currencyCode: "hkd" }, { countryCode: "hk", countryName: "Hong Kong", currencyCode: "hkd" },
{ countryCode: "jp", currencyCode: "jpy" }, { countryCode: "jp", countryName: "Japan", currencyCode: "jpy" },
{ countryCode: "my", currencyCode: "myr" }, { countryCode: "my", countryName: "Malaysia", currencyCode: "myr" },
{ countryCode: "nz", currencyCode: "nzd" }, { countryCode: "nz", countryName: "New Zealand", currencyCode: "nzd" },
{ countryCode: "ph", currencyCode: "php" }, { countryCode: "ph", countryName: "Philippines", currencyCode: "php" },
{ countryCode: "sg", currencyCode: "sgd" }, { countryCode: "sg", countryName: "Singapore", currencyCode: "sgd" },
{ countryCode: "tw", currencyCode: "twd" }, { countryCode: "tw", countryName: "Taiwan", currencyCode: "twd" },
{ countryCode: "th", currencyCode: "usd" }, { countryCode: "th", countryName: "Thailand", currencyCode: "usd" },
{ countryCode: "at", currencyCode: "eur" }, { countryCode: "at", countryName: "Austria", currencyCode: "eur" },
{ countryCode: "be", currencyCode: "eur" }, { countryCode: "be", countryName: "Belgium", currencyCode: "eur" },
{ countryCode: "cy", currencyCode: "eur" }, { countryCode: "cy", countryName: "Cyprus", currencyCode: "eur" },
{ countryCode: "cz", currencyCode: "czk" }, { countryCode: "cz", countryName: "Czech Republic", currencyCode: "czk" },
{ countryCode: "dk", currencyCode: "dkk" }, { countryCode: "dk", countryName: "Denmark", currencyCode: "dkk" },
{ countryCode: "ee", currencyCode: "eur" }, { countryCode: "ee", countryName: "Estonia", currencyCode: "eur" },
{ countryCode: "fi", currencyCode: "eur" }, { countryCode: "fi", countryName: "Finland", currencyCode: "eur" },
{ countryCode: "fr", currencyCode: "eur" }, { countryCode: "fr", countryName: "France", currencyCode: "eur" },
{ countryCode: "de", currencyCode: "eur" }, { countryCode: "de", countryName: "Germany", currencyCode: "eur" },
{ countryCode: "gr", currencyCode: "eur" }, { countryCode: "gr", countryName: "Greece", currencyCode: "eur" },
{ countryCode: "ie", currencyCode: "eur" }, { countryCode: "ie", countryName: "Ireland", currencyCode: "eur" },
{ countryCode: "it", currencyCode: "eur" }, { countryCode: "it", countryName: "Italy", currencyCode: "eur" },
{ countryCode: "lt", currencyCode: "eur" }, { countryCode: "lt", countryName: "Lithuania", currencyCode: "eur" },
{ countryCode: "lu", currencyCode: "eur" }, { countryCode: "lu", countryName: "Luxembourg", currencyCode: "eur" },
{ countryCode: "mt", currencyCode: "eur" }, { countryCode: "mt", countryName: "Malta", currencyCode: "eur" },
{ countryCode: "nl", currencyCode: "eur" }, { countryCode: "nl", countryName: "Netherlands", currencyCode: "eur" },
{ countryCode: "no", currencyCode: "nok" }, { countryCode: "no", countryName: "Norway", currencyCode: "nok" },
{ countryCode: "pl", currencyCode: "pln" }, { countryCode: "pl", countryName: "Poland", currencyCode: "pln" },
{ countryCode: "pt", currencyCode: "eur" }, { countryCode: "pt", countryName: "Portugal", currencyCode: "eur" },
{ countryCode: "ro", currencyCode: "ron" }, { countryCode: "ro", countryName: "Romania", currencyCode: "ron" },
{ countryCode: "sk", currencyCode: "eur" }, { countryCode: "sk", countryName: "Slovakia", currencyCode: "eur" },
{ countryCode: "si", currencyCode: "eur" }, { countryCode: "si", countryName: "Slovenia", currencyCode: "eur" },
{ countryCode: "es", currencyCode: "eur" }, { countryCode: "es", countryName: "Spain", currencyCode: "eur" },
{ countryCode: "se", currencyCode: "sek" }, { countryCode: "se", countryName: "Sweden", currencyCode: "sek" },
{ countryCode: "ch", currencyCode: "chf" }, { countryCode: "ch", countryName: "Switzerland", currencyCode: "chf" },
{ countryCode: "tr", currencyCode: "try" }, { countryCode: "tr", countryName: "Turkey", currencyCode: "try" },
{ countryCode: "gb", currencyCode: "gbp" }, { countryCode: "gb", countryName: "United Kingdom", currencyCode: "gbp" },
{ countryCode: "ar", currencyCode: "usd" }, { countryCode: "ar", countryName: "Argentina", currencyCode: "usd" },
{ countryCode: "br", currencyCode: "brl" }, { countryCode: "br", countryName: "Brazil", currencyCode: "brl" },
{ countryCode: "cl", currencyCode: "clp" }, { countryCode: "cl", countryName: "Chile", currencyCode: "clp" },
{ countryCode: "co", currencyCode: "cop" }, { countryCode: "co", countryName: "Colombia", currencyCode: "cop" },
{ countryCode: "mx", currencyCode: "mxn" }, { countryCode: "mx", countryName: "Mexico", currencyCode: "mxn" },
{ countryCode: "pe", currencyCode: "pen" }, { countryCode: "pe", countryName: "Peru", currencyCode: "pen" },
]; ];

View file

@ -1,13 +1,37 @@
const timeout = (duration) => new Promise((r) => setTimeout(r, duration)); const timeout = (duration) => new Promise((r) => setTimeout(r, duration));
// Selectors for both old and new Kobo layouts
const PRICE_SELECTORS = [
// Old layout (pre-2025 redesign)
".primary-right-container .pricing-details .active-price span",
// New layout (Tailwind, data-testid based)
'[data-testid="price"] .text-title-m-bold',
];
const findPrice = (page) => {
for (const sel of PRICE_SELECTORS) {
const el = page?.querySelector(sel);
if (el?.textContent?.trim()) {
return el.textContent.trim();
}
}
return null;
};
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(() => { // Check immediately first
const price = page?.querySelector( const immediate = findPrice(page);
".primary-right-container .pricing-details .active-price span", if (immediate) {
)?.textContent; l("found price (immediate)", immediate);
res(immediate);
return;
}
const observer = new MutationObserver(() => {
const price = findPrice(page);
if (price) { if (price) {
l("found price", price); l("found price", price);

View file

@ -12,197 +12,241 @@ TODO:
- readme how to use and debug - readme how to use and debug
*/ */
const createPricesContainer = () => { const isInLibrary = () =>
const pricingActionContainers = document.querySelectorAll(".pricing-details"); // Old layout
document.querySelectorAll(".read-now").length > 0 ||
l("all pricing containers", pricingActionContainers); // New layout
document.querySelectorAll('[data-testid="buypad-readnow"]').length > 0;
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,
);
};
const isInLibrary = () => document.querySelectorAll(".read-now").length;
const formatPrice = (countryPrice, convertedPrice, isSelected) => { const formatPrice = (countryPrice, convertedPrice, isSelected) => {
if (!isSelected) return "SKIP"; if (!isSelected) return "SKIP";
if (countryPrice == undefined) { if (countryPrice == undefined) {
return "NOT LOADED"; return "NOT LOADED";
} }
if (convertedPrice) { if (convertedPrice) {
return `${countryPrice} => ${convertedPrice?.formatted}`; return `${countryPrice} => ${convertedPrice?.formatted}`;
} }
return "NOT FOUND"; return "NOT FOUND";
}; };
const Price = ({ price, toggleCountry, isSelected }) => { const Price = ({ price, toggleCountry, isSelected }) => {
const { convertedPrice, countryCode, countryPrice } = price; const { convertedPrice, countryCode, countryName, 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",
alignItems: "center", alignItems: "center",
backgroundColor: isHovered ? "#d7d7d7" : "", backgroundColor: isHovered ? "#d7d7d7" : "",
padding: "0 10px", padding: "4px 10px",
}; };
const link = h( const checkbox = h("input", {
"a", type: "checkbox",
{ checked: isSelected,
style: { textDecoration: "underline" }, onChange: () => toggleCountry(),
href: bookUrl(countryCode), });
target: "_blank",
},
countryCode.toUpperCase(),
);
const checkbox = h("input", { const link = h(
type: "checkbox", "a",
checked: isSelected, {
onChange: () => toggleCountry(), style: { textDecoration: "underline", marginLeft: "8px" },
}); href: bookUrl(countryCode),
target: "_blank",
},
countryName || countryCode.toUpperCase(),
);
const priceLabel = h( const priceLabel = h(
"p", "p",
{ style: { fontWeight: "bold" } }, { style: { fontWeight: "bold", marginLeft: "auto" } },
formatPrice(countryPrice, convertedPrice, isSelected), formatPrice(countryPrice, convertedPrice, isSelected),
); );
return h( return h(
"label", "label",
{ style, onMouseEnter: hover, onMouseLeave: leave }, { style, onMouseEnter: hover, onMouseLeave: leave },
h("div", { style: { display: "flex" } }, checkbox, link), checkbox,
priceLabel, link,
); priceLabel,
);
}; };
const InLibrary = () => const InLibrary = () =>
h( h(
"div", "div",
{ {
style: { style: {
display: "flex", padding: "10px",
flexDirection: "column", textAlign: "center",
border: "1px solid black", },
textAlign: "center", },
}, h("h2", null, "Already in Library!"),
}, );
h("h2", null, "Already in Library!"),
);
const Percent = (percent) => { const Percent = (percent) => {
const spinner = useRef(); const spinner = useRef();
useEffect(() => { useEffect(() => {
spinner.current.animate( spinner.current.animate(
[{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }], [{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],
{ iterations: Infinity, duration: 1000 }, { iterations: Infinity, duration: 1000 },
); );
}, []); }, []);
const style = { padding: "10px 10px 0 10px", textAlign: "center" }; const style = { padding: "10px 10px 0 10px", textAlign: "center" };
if (percent == 100) return h("h2", { style }, "all prices are loaded!"); if (percent == 100) return h("h2", { style }, "all prices are loaded!");
return h( return h(
"h2", "h2",
{ style }, { style },
`${percent}%`, `${percent}%`,
h("span", { h("span", {
ref: spinner, ref: spinner,
style: { style: {
width: "16px", width: "16px",
height: "16px", height: "16px",
borderRadius: "50%", borderRadius: "50%",
display: "inline-block", display: "inline-block",
borderTop: "3px solid black", borderTop: "3px solid black",
borderRight: "3px solid transparent", borderRight: "3px solid transparent",
boxSizing: "border-box", boxSizing: "border-box",
marginLeft: "15px", marginLeft: "15px",
}, },
}), }),
); );
}; };
const Header = (state, percentChecked) => { const Header = (state, percentChecked) => {
if (state === "loading") return Percent(percentChecked); if (state === "loading") return Percent(percentChecked);
if (state === "done") if (state === "done")
return h( return h(
"h2", "h2",
{ style: { textAlign: "center" } }, { style: { textAlign: "center" } },
"All prices are loaded!", "All prices are loaded!",
); );
return h( return h(
"h2", "h2",
{ style: { textAlign: "center" } }, { style: { textAlign: "center" } },
'Select countries or leave as is and press "load"', 'Select countries or leave as is and press "Load prices"',
); );
}; };
const Load = (state, load) => { const Load = (state, load) => {
return h( return h(
"button", "button",
{ {
disabled: state === "loading", disabled: state === "loading",
onClick: load, onClick: load,
type: "button", type: "button",
style: { backgroundColor: "#91ff91" }, style: { backgroundColor: "#91ff91" },
}, },
state === "loading" ? "Loading..." : "Load prices", state === "loading" ? "Loading..." : "Load prices",
); );
}; };
const App = () => { const App = () => {
const { isSelected, toggleCountry, selected } = useSelectedCountries(); const { isSelected, toggleCountry, selected } = useSelectedCountries();
const { prices, percentChecked, state, load } = usePrices(selected); const { prices, percentChecked, state, load } = usePrices(selected);
const [expanded, setExpanded] = useState(false);
l(selected); l(selected);
const style = {
display: "flex",
flexDirection: "column",
border: "1px solid black",
};
if (isInLibrary()) { const toggle = useCallback(() => setExpanded((v) => !v));
return InLibrary();
}
return h( if (isInLibrary()) {
"div", return h(
{ style }, "div",
Header(state, percentChecked), {
...prices.map((price) => style: {
h(Price, { position: "fixed",
price, top: "10px",
toggleCountry: () => toggleCountry(price.countryCode), left: "10px",
isSelected: isSelected(price.countryCode), zIndex: 99999,
isLoading: state, background: "white",
}), border: "1px solid black",
), fontFamily: "sans-serif",
Load(state, load), fontSize: "14px",
); },
},
InLibrary(),
);
}
const panelStyle = {
position: "fixed",
bottom: "0",
left: "0",
zIndex: 99999,
background: "white",
border: "1px solid black",
fontFamily: "sans-serif",
fontSize: "14px",
maxHeight: expanded ? "100vh" : "auto",
overflowY: expanded ? "auto" : "hidden",
minWidth: "100%",
};
const buttonStyle = {
height: "2.25rem",
background: "white",
border: "1px solid black",
// padding: "6px 12px",
cursor: "pointer",
fontSize: "14px",
fontWeight: "bold",
width: "100%",
};
if (!expanded) {
return h(
"div",
{ style: panelStyle },
h("button", { style: buttonStyle, onClick: toggle }, "PRICES"),
);
}
const listStyle = {
display: "flex",
flexDirection: "column",
};
return h(
"div",
{ style: panelStyle },
h("button", { style: buttonStyle, onClick: toggle }, "CLOSE"),
h(
"div",
{ style: listStyle },
...[
Header(state, percentChecked),
Load(state, load),
...prices.map((price) =>
h(Price, {
price,
toggleCountry: () => toggleCountry(price.countryCode),
isSelected: isSelected(price.countryCode),
isLoading: state,
}),
),
],
),
);
}; };
l("starting..."); l("starting...");
initCache(); initCache();
render(createElement(App), createPricesContainer());
const container = document.createElement("div");
document.body.appendChild(container);
render(createElement(App), container);

File diff suppressed because one or more lines are too long