Greasy Fork

Kobo e-Books Update Checker

Checks if updates were available for the e-books you own.

当前为 2023-12-16 提交的版本,查看 最新版本

// ==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.0.0
// @license            MIT
// @match              https://www.kobo.com/*/library/books
// @run-at             document-end
// @grant              GM.addStyle
// @require            https://update.greasyfork.org/scripts/482311/1296526/queue.js
// ==/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",
            CHECK_UPDATE: "Check Update",
            QUEUED: "Queued",
            CHECKING: "Checking...",
            LATEST: "Latest",
            OUTDATED: "Outdated",
            PREVIEW: "Preview",
            FAILED: "Failed",
            NOT_SUPPORTED: "Not Supported",
            BOOK_WAS_UNLISTED: "The book was unlisted, there’s no way to check update for this type of books at the moment.",
            BOOK_IS_AUDIOBOOK: "The book is audiobook, there’s no way to check update for this type of books at the moment.",
            UNKNOWN_ERROR: "Unknown error, please contact the developer for further investigations.",
        },
        zh: {
            CHECK_UPDATE_FOR_PAGE: "為本頁檢查更新",
            CHECK_UPDATE: "檢查更新",
            QUEUED: "等待中",
            CHECKING: "檢查中…",
            LATEST: "最新",
            OUTDATED: "過時",
            PREVIEW: "預覽",
            FAILED: "檢查失敗",
            NOT_SUPPORTED: "不支援",
            BOOK_WAS_UNLISTED: "該書籍已下架,目前尚未有方法為這類書籍檢查更新。",
            BOOK_IS_AUDIOBOOK: "該書籍為有聲書,目前尚未有方法為這類書籍檢查更新。",
            UNKNOWN_ERROR: "未知錯誤,請聯絡開發者以進一步調查。",
        },
    };

    const { language } = location.pathname.match(PATHNAME_PREFIX_PATTERN).groups;
    MESSAGES = Object.hasOwn(I18N, language) ? I18N[language] : I18N.en;
}

GM.addStyle(`
    .library-container .check-update-container
    {
        display: inline;
    }

    .library-container .check-update-controls.filter-chip, .library-container .check-update-controls .check-update-button
    {
        width: auto;
    }

    .library-container .check-update-button
    {
        display: flex;
        border-radius: 20px;
        min-width: 0;
        max-width: 100%;
        width: 100%;
        justify-content: space-between;
        align-items: center;
        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 .check-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 .check-update-button:hover
    {
        background-color: rgba(0, 0, 0, .04);
    }

    .library-container .check-update-button:active
    {
        background-color: #000;
        color: #fff;
    }

    .library-container .check-update-button:focus::before
    {
        opacity: 1;
        transform: scale(1);
    }

    @media (max-width: 745px)
    {
        .library-container .check-update-container
        {
            display: block;
            text-align: left;
        }

        .library-container .check-update-controls
        {
            margin-right: 18px;
        }
    }

    .product-field.item-status.outdated
    {
        background: #FE8484;
    }

    .product-field.item-status:is(.failed, .audiobook) a
    {
        cursor: pointer;
    }
`);

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 items = document.querySelectorAll(".library-items > li");
for (const item of items)
{
    const actions = item.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(item));

    actionContainer.appendChild(action);
    actions.appendChild(actionContainer);
}

{
    const controls = document.querySelector(".secondary-controls");

    const container = document.createElement("div");
    container.classList.add("check-update-container");

    const wrapper = document.createElement("div");
    wrapper.classList.add("check-update-controls", "filter-chip");

    const button = document.createElement("button");
    button.classList.add("check-update-button");
    button.textContent = MESSAGES.CHECK_UPDATE_FOR_PAGE;
    button.addEventListener("click", () => items.forEach(checkUpdate));

    wrapper.appendChild(button);
    container.appendChild(wrapper);
    controls.insertBefore(container, controls.firstChild);
}

const queue = new Queue({ autostart: true, concurrency: 6 });

function checkUpdate(item)
{
    const message = item.querySelector(".product-field.item-status");
    message.replaceChildren(MESSAGES.QUEUED);

    if (item.dataset.koboGizmo === "PreviewLibraryItem")
    {
        message.classList.remove("buy-now");
        message.replaceChildren(MESSAGES.PREVIEW);
        return;
    }

    const storeLink = item.querySelector(".product-field.title a");
    if (STORE_AUDIOBOOK_URL_PATTERN.test(storeLink.href))
    {
        message.classList.add("audiobook");

        const link = document.createElement("a");
        link.textContent = MESSAGES.NOT_SUPPORTED;
        link.addEventListener("click", (event) => alert(MESSAGES.BOOK_IS_AUDIOBOOK));

        message.replaceChildren(link);
        return;
    }

    queue.push(async () =>
    {
        message.textContent = MESSAGES.CHECKING;

        try
        {
            const currentId = getCurrentProductId();
            const latestId = await getLatestProductId(storeLink.href);
            console.debug(`${storeLink.innerText}\n  Current: ${currentId}\n  Latest : ${latestId}`);

            if (currentId === latestId)
            {
                message.classList.remove("outdated", "failed");
                message.replaceChildren(MESSAGES.LATEST);
            }
            else
            {
                message.classList.add("failed");
                message.replaceChildren(MESSAGES.OUTDATED);
                message.classList.add("outdated");
            }
        }
        catch (e)
        {
            message.classList.remove("outdated");
            message.classList.add("failed");

            const link = document.createElement("a");
            link.textContent = MESSAGES.FAILED;
            link.addEventListener("click", (event) => alert(e.message));

            message.replaceChildren(link);
        }
    });

    function getCurrentProductId()
    {
        const matches = item.querySelector(".library-action.readnow").href.match(READING_URL_PATTERN);
        return matches?.groups.id;
    }

    async function getLatestProductId(url)
    {
        const response = await fetch(url);
        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; }

        throw new Error(MESSAGES.UNKNOWN_ERROR);
    }
}