// ==UserScript==
// @name Kobo e-Books Update Checker
// @name:zh-TW Kobo 電子書更新檢查器
// @description Checks if updates were available for the e-books you own.
// @description:zh-TW 檢查你購買的電子書是否有更新檔提供。
// @icon https://icons.duckduckgo.com/ip3/www.kobo.com.ico
// @author Jason Kwok
// @namespace https://jasonhk.dev/
// @version 1.4.0
// @license MIT
// @match https://www.kobo.com/*/library/books
// @run-at document-end
// @grant GM.addStyle
// @require https://update.gf.qytechs.cn/scripts/482311/1297431/queue.js
// @supportURL https://gf.qytechs.cn/scripts/482410/feedback
// ==/UserScript==
let MESSAGES;
{
const PATHNAME_PREFIX_PATTERN = /\/(?<region>[a-z]{2})\/(?<language>[a-z]{2})\//;
const I18N = {
en: {
CHECK_UPDATE_FOR_PAGE: "Check Update for Page",
FINISHED_CHECKING_ALL_BOOKS: "Finished checking all books for this page.\n\nLatest: {0}\nOutdated: {1}\nPreview: {2}\nSkipped{3}\nFailed: {4}",
COPY_OUTDATED_BOOKS: "Copy Outdated Books",
NO_BOOKS_WERE_OUTDATED: "No books were outdated.",
COPIED_N_BOOKS_INTO_CLIPBOARD: "Copied {0} book(s) into the clipboard.",
CHECK_UPDATE: "Check Update",
PENDING: "Pending...",
CHECKING: "Checking...",
LATEST: "Latest",
OUTDATED: "Outdated",
PREVIEW: "Preview",
SKIPPED: "Skipped",
FAILED: "Failed",
BOOK_WAS_UNLISTED: "This book was unlisted, there’s no way to check update for this type of books at the moment.",
FETCH_PRODUCT_ID_FAILED: "Failed to fetch the latest product ID, please contact the developer for further investigations.",
UNKNOWN_ERROR: "Unknown error, please contact the developer for further investigations.",
},
zh: {
CHECK_UPDATE_FOR_PAGE: "為本頁檢查更新",
FINISHED_CHECKING_ALL_BOOKS: "完成檢查本頁的書籍。\n\n最新:{0}\n過時:{1}\n預覽:{2}\n已略過:{3}\n檢查失敗:{4}",
COPY_OUTDATED_BOOKS: "複製過時書籍",
NO_BOOKS_WERE_OUTDATED: "沒有過時的書籍。",
COPIED_N_BOOKS_INTO_CLIPBOARD: "已複製 {0} 本書到剪貼簿。",
CHECK_UPDATE: "檢查更新",
PENDING: "等待中…",
CHECKING: "檢查中…",
LATEST: "最新",
OUTDATED: "過時",
PREVIEW: "預覽",
SKIPPED: "已略過",
FAILED: "檢查失敗",
BOOK_WAS_UNLISTED: "該書已下架,目前尚未有方法為這類書籍檢查更新。",
FETCH_PRODUCT_ID_FAILED: "無法取得最新的產品編號,請聯絡開發者以進一步調查。",
UNKNOWN_ERROR: "未知錯誤,請聯絡開發者以進一步調查。",
},
};
const { language } = location.pathname.match(PATHNAME_PREFIX_PATTERN).groups;
MESSAGES = Object.hasOwn(I18N, language) ? I18N[language] : I18N.en;
}
GM.addStyle(`
.library-container .update-container
{
text-align: right;
}
.library-container .update-controls
{
position: relative;
display: inline-block;
margin-top: 18px;
min-width: 13rem;
width: auto;
}
.library-container .update-button
{
border-radius: 20px;
min-width: 0;
max-width: 100%;
width: auto;
overflow: hidden;
background-color: #eee;
color: #000;
font-size: 1.6rem;
font-family: "Rakuten Sans UI", "Trebuchet MS", Trebuchet, Arial, Helvetica, sans-serif;
font-weight: 400;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
position: relative;
white-space: nowrap;
transition: background-color .3s ease-in-out, color .15s ease-in-out 0s;
}
.library-container .update-button:not(:first-child)
{
margin-left: 5px;
}
.library-container .update-button:not(:last-child)
{
margin-right: 5px;
}
.library-container .update-button::before
{
position: absolute;
top: calc(50% - 30px);
border-radius: 80px;
width: calc(100% - 30px);
height: 60px;
background-color: rgba(0, 0, 0, .1);
content: "";
opacity: 0;
transform: scale(0);
}
.library-container .update-button:hover
{
background-color: rgba(0, 0, 0, .04);
}
.library-container .update-button:focus::before
{
opacity: 1;
transform: scale(1);
}
.library-container .update-button:active
{
background-color: #000;
color: #fff;
}
@media (max-width: 568px)
{
.library-container .secondary-controls
{
margin-right: 18px;
}
.library-container .update-container
{
text-align: left;
}
.library-container .update-controls
{
margin-right: 0;
width: 100%;
white-space: break-spaces;
}
.library-container .update-button
{
margin-left: 0 !important;
margin-right: 0 !important;
width: 100%;
text-align: center;
}
.library-container .update-button:not(:first-child)
{
margin-top: 18px;
}
.library-container .library-content.grid .more-actions:not(.open)
{
width: fit-content;
transform: translateY(35px);
}
}
@media (max-width: 378px)
{
}
.item-wrapper.book[data-check-status=outdated] .product-field.item-status
{
background: #FE8484;
}
.item-wrapper.book:is([data-check-status=skipped], [data-check-status=failed]) .product-field.item-status a
{
text-decoration-line: underline;
cursor: pointer;
}
`);
const STATUS_PENDING = "pending";
const STATUS_CHECKING = "checking";
const STATUS_LATEST = "latest";
const STATUS_OUTDATED = "outdated";
const STATUS_PREVIEW = "preview";
const STATUS_SKIPPED = "skipped";
const STATUS_FAILED = "failed";
const READING_URL_PATTERN = /\/ReadNow\/(?<id>[0-9a-f-]{36})/;
const STORE_AUDIOBOOK_URL_PATTERN = /\/(?<region>[a-z]{2})\/(?<language>[a-z]{2})\/audiobook\//;
const queue = new Queue({ autostart: true, concurrency: 6 });
const books = document.querySelectorAll(".item-wrapper.book");
for (const book of books)
{
const actions = book.querySelector(".item-info + .item-bar .library-actions-list");
const actionContainer = document.createElement("li");
actionContainer.classList.add("library-actions-list-item");
const action = document.createElement("button");
action.classList.add("library-action");
action.textContent = MESSAGES.CHECK_UPDATE;
action.addEventListener("click", () => checkUpdate(book));
actionContainer.appendChild(action);
actions.appendChild(actionContainer);
}
{
const controls = document.querySelector(".secondary-controls");
const container = document.createElement("div");
container.classList.add("update-container");
const wrapper = document.createElement("div");
wrapper.classList.add("update-controls");
const checkButton = document.createElement("button");
checkButton.classList.add("update-button");
checkButton.textContent = MESSAGES.CHECK_UPDATE_FOR_PAGE;
checkButton.addEventListener("click", () => checkUpdateForBooks(books));
const copyButton = document.createElement("button");
copyButton.classList.add("update-button");
copyButton.textContent = MESSAGES.COPY_OUTDATED_BOOKS;
copyButton.addEventListener("click", () =>
{
const books = document.querySelectorAll(".item-wrapper.book[data-check-status=outdated]");
navigator.clipboard.writeText(Array.prototype.map.call(books, getBookTitle).join("\n"));
alert((books.length === 0) ? MESSAGES.NO_BOOKS_WERE_OUTDATED : buildMessage(MESSAGES.COPIED_N_BOOKS_INTO_CLIPBOARD, [books.length]));
});
wrapper.append(checkButton, copyButton);
container.appendChild(wrapper);
controls.insertBefore(container, controls.firstChild);
}
function isAudiobook(book)
{
return STORE_AUDIOBOOK_URL_PATTERN.test(book.querySelector(".product-field.title a").href);
}
function getBookTitle(book)
{
return book.querySelector(".product-field.title").innerText;
}
function getCurrentProductId(book)
{
const config = JSON.parse(book.querySelector(".library-action.mark-as-finished").dataset.koboGizmoConfig);
return config.productId;
}
async function getLatestProductId(book)
{
const response = await fetch(book.querySelector(".product-field.title a").href);
if (!response.ok)
{
if (response.status === 404)
{
throw new Error(MESSAGES.BOOK_WAS_UNLISTED);
}
throw new Error(MESSAGES.UNKNOWN_ERROR);
}
const html = await response.text();
const parser = new DOMParser();
const page = parser.parseFromString(html, "text/html");
const itemId = page.querySelector("#ratItemId");
if (itemId) { return itemId.value; }
const config = page.querySelector(".item-detail");
if (config) { return JSON.parse(config.dataset.koboGizmoConfig).productId; }
throw new Error(MESSAGES.UNKNOWN_ERROR);
}
function buildMessage(message, replacements)
{
return message.replaceAll(/\{(\d+)\}/g, (_, index) => replacements[index]);
}
function checkUpdate(book)
{
const message = book.querySelector(".product-field.item-status");
book.dataset.checkStatus = STATUS_PENDING;
message.replaceChildren(MESSAGES.PENDING);
queue.push(async () =>
{
book.dataset.checkStatus = STATUS_CHECKING;
message.textContent = MESSAGES.CHECKING;
if (book.dataset.koboGizmo === "PreviewLibraryItem")
{
book.dataset.checkStatus = STATUS_SKIPPED;
message.classList.remove("buy-now");
message.replaceChildren(MESSAGES.PREVIEW);
return;
}
try
{
const currentId = getCurrentProductId(book);
const latestId = await getLatestProductId(book);
console.debug(`${getBookTitle(book)}\n Current: ${currentId}\n Latest : ${latestId}`);
if (currentId === latestId)
{
book.dataset.checkStatus = STATUS_LATEST;
message.replaceChildren(MESSAGES.LATEST);
}
else
{
book.dataset.checkStatus = STATUS_OUTDATED;
message.replaceChildren(MESSAGES.OUTDATED);
}
}
catch (e)
{
book.dataset.checkStatus = STATUS_FAILED;
const link = document.createElement("a");
link.textContent = MESSAGES.FAILED;
link.addEventListener("click", (event) => alert(e.message));
message.replaceChildren(link);
}
});
}
function checkUpdateForBooks(books)
{
queue.addEventListener("end", () =>
{
let latest = 0;
let outdated = 0;
let preview = 0;
let skipped = 0;
let failed = 0;
for (const book of books)
{
switch (book.dataset.checkStatus)
{
case STATUS_LATEST:
latest++;
break;
case STATUS_OUTDATED:
outdated++;
break;
case STATUS_PREVIEW:
preview++;
break;
case STATUS_SKIPPED:
skipped++
break;
case STATUS_FAILED:
failed++;
break;
}
}
alert(buildMessage(MESSAGES.FINISHED_CHECKING_ALL_BOOKS, [latest, outdated, preview, skipped, failed]));
}, { once: true });
books.forEach(checkUpdate);
}