Japanese Reading Tracker

Keeps track of characters read in popular japanese websites like syosetu.com, etc.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Japanese Reading Tracker
// @description  Keeps track of characters read in popular japanese websites like syosetu.com, etc.
// @version      1.3.1
// @author       nenlitiochristian
// @match        https://syosetu.org/*
// @match        https://kakuyomu.jp/*
// @match        https://ncode.syosetu.com/*
// @license      MIT
// @namespace    JP_reading_tracker_nc
// ==/UserScript==

(function () {
    'use strict';
    // credit to cademcniven for this
    function countJapaneseCharacters(japaneseText) {
        const regex = /[一-龠]+|[ぁ-ゔ]+|[ァ-ヴー]+|[a-zA-Z0-9]+|[々〆〤ヶ]+/g
        return [...japaneseText.matchAll(regex)].join('').length
    }

    /**
     * @typedef {Object} Chapter
     * @property {string} title - The title of the chapter.
     * @property {number} characters - The number of characters read in the chapter.
     */

    /**
     * @typedef {Object} Novel
     * @property {Object.<string, Chapter>} readChapters - A map where the key is the chapter ID and the value is a `Chapter` object.
     */

    /**
     * Makes a new empty novel
     * @returns {Novel} 
     */
    function newNovel() {
        return {
            readChapters: {},
        }
    }

    /**
     * @param {string} id - The unique identifier for the novel.
     */
    function initializeStorage(id) {
        localStorage.setItem(id, JSON.stringify(newNovel()));
    }

    /**
     * @param {Novel} novel
     * @returns {number}
     */
    function countTotalCharacters(novel) {
        let counter = 0;
        // Sum up the character count from all chapters
        Object.entries(novel.readChapters).forEach(([_, value]) => {
            counter += value.characters;
        });
        return counter;
    }

    /**
     * @param {Novel} novel
     * @returns {string}
     */
    function exportCSV(novel) {
        let string = "";
        Object.entries(novel.readChapters).forEach(([key, value]) => {
            string += `${key},${value.title},${value.characters}\n`
        });
    }

    /**
     * @returns {string}
     */
    function getHostname() {
        return window.location.hostname;
    }

    class SiteStrategy {
        isInNovelPage() {
            throw new Error("Method not implemented.");
        }
        getNovelId() {
            throw new Error("Method not implemented.");
        }
        handleOldNovel(id) {
            throw new Error("Method not implemented.");
        }

        /**
         * @param {string} id
         * @param {Novel} novelData 
         */
        renderCounter(id, novelData) {
            // inject styles 
            const styles = `#tracker-button { position: fixed; bottom: 20px; right: 20px; background-color: #333; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; z-index: 1000; box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.5); user-select: none; } 
            .overlay-container { position: fixed; left: 0; top: 0; width: 100%; height: 100%; justify-content: center; align-items: center; display: none; z-index: 1001; font-size: 16px; background: rgba(0, 0, 0, 0.5); }
            #tracker-popup { height: 90%; width: calc(200px + 40%); background-color: #222; color: #fff; padding: 20px; border-radius: 10px; box-shadow: 0px 4px 10px rgba(0,0,0,0.5); display: flex; flex-direction: column; }
            #tracker-popup h2 { border-bottom: 1px solid #444; padding-bottom: 10px; } 
            .table-list { padding-top: 4px; margin-bottom: auto; width: 100%; display: block; overflow-y: auto; } 
            .table-list th, .table-list td { padding: 5px; } 
            .delete-button { background-color: #ff6347; color: #fff; border: none; padding: 5px; cursor: pointer; border-radius: 3px; } 
            .close-button { background-color: #444; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; margin-top: 20px; width: fit-content; } `;

            const styleSheet = document.createElement("style");
            styleSheet.innerText = styles;
            document.head.appendChild(styleSheet);

            // add button to display the popup
            const button = document.createElement('button');
            button.id = 'tracker-button';
            button.textContent = `🍞`;

            document.body.appendChild(button);

            const overlayContainer = document.createElement('div');
            overlayContainer.classList.add('overlay-container');

            const popup = document.createElement('div');
            popup.id = 'tracker-popup';

            // Add content to the popup
            const title = document.createElement('h2');
            title.textContent = `合計文字数:${countTotalCharacters(novelData)}`;
            popup.appendChild(title);

            // List of tracked chapters
            const chapterList = document.createElement('table');
            chapterList.classList.add('table-list');



            const listHeader = document.createElement('thead');
            listHeader.innerHTML = `<tr>
                <th style="width:32px;">#</th> <th style="width:75%;">タイトル</th> <th>文字数</th> <th style="width:64px;"></th>
            </tr>`;

            chapterList.append(listHeader);

            const listBody = document.createElement('tbody');
            chapterList.append(listBody);

            let index = 1;
            Object.entries(novelData.readChapters).sort((a, b) => parseInt(a) - parseInt(b)).forEach(([key, chapter]) => {
                const listItem = document.createElement('tr');
                listItem.innerHTML = `
                <td>${index}</td> <td style="width:auto;">${chapter.title}</td> <td>${chapter.characters}</td>
                <td>
                    <button data-chapter="${key}" class="delete-button">削除</button>
                </td>`;

                listItem.querySelector('button').addEventListener('click', () => {
                    const { [key]: _, ...updatedChapters } = novelData.readChapters;
                    novelData.readChapters = updatedChapters;
                    localStorage.setItem(id, JSON.stringify(novelData)); // Update the novel data in localStorage
                    window.location.reload(); // Reload to update UI
                });

                listBody.appendChild(listItem);
                index++;
            });

            popup.appendChild(chapterList);

            // Add close button
            const closeButton = document.createElement('button');
            closeButton.textContent = '閉じる';
            closeButton.classList.add('close-button');

            closeButton.addEventListener('click', () => {
                overlayContainer.style.display = 'none';
            });

            popup.appendChild(closeButton);

            overlayContainer.appendChild(popup);
            document.body.appendChild(overlayContainer);

            button.addEventListener('click', () => {
                overlayContainer.style.display = overlayContainer.style.display === 'none' ? 'flex' : 'none';
            });
        }

    }

    class SyosetuOrg extends SiteStrategy {
        // https://syosetu.org/novel/{id}/{chapter}.html
        // split by "/"
        // 1 -> gets "novel"
        // 2 -> gets {id}
        // 3 -> gets {chapter}
        isInNovelPage() {
            return window.location.pathname.split("/")[1] === "novel";
        }

        getNovelId() {
            return window.location.pathname.split("/")[2];
        }

        handleOldNovel(id) {
            // get the current chapter from the URL (if any)
            let chapterId = window.location.pathname.split("/")[3];
            const currentNovelData = JSON.parse(localStorage.getItem(id));

            // if we are not in a chapter page, just return the existing novel data
            if (!chapterId) {
                return currentNovelData;
            }

            // syosetu.org has .html attached to the number, we remove it
            chapterId = chapterId.split(".")[0];

            // Get the chapter content and calculate the character count
            const chapterContent = document.querySelector("#honbun");
            const chapterText = [...chapterContent.childNodes].map((node) => node.textContent).join("");

            // Create a new chapter entry
            // syosetu.org has 2 utterly different html pages for desktop and mobile
            const titles = document.querySelectorAll('span[style="font-size:120%"]')
            let newChapter = {};

            // if desktop
            if (titles.length === 2) {
                newChapter.title = titles[1].textContent ?? "Unknown"
                newChapter.characters = countJapaneseCharacters(chapterText)
            }
            // if mobile
            else {
                newChapter.title = document.querySelector("h2").textContent ?? "Unknown"
                newChapter.characters = countJapaneseCharacters(chapterText)
            }

            // Update the novel data with the new chapter and store it in localStorage
            currentNovelData.readChapters = { ...currentNovelData.readChapters, [chapterId]: newChapter };
            localStorage.setItem(id, JSON.stringify(currentNovelData));

            return currentNovelData;
        }
    }


    class KakuyomuJp extends SiteStrategy {
        // https://kakuyomu.jp/works/{novel}/episodes/{chapter}
        // split by /
        // 1 -> works
        // 2 -> {novel}
        // 4 -> {chapter}
        isInNovelPage() {
            return window.location.pathname.split("/")[1] === "works";
        }

        getNovelId() {
            return window.location.pathname.split("/")[2];
        }

        handleOldNovel(id) {
            // get the current chapter from the URL (if any)
            let chapterId = window.location.pathname.split("/")[4];
            const currentNovelData = JSON.parse(localStorage.getItem(id));

            // if we are not in a chapter page, just return the existing novel data
            if (!chapterId) {
                return currentNovelData;
            }

            // Get the chapter content and calculate the character count
            const chapterContent = document.querySelector(".widget-episodeBody");
            const chapterText = [...chapterContent.childNodes].map((node) => node.textContent).join("");

            const newChapter = {
                title: document.querySelector(".widget-episodeTitle").textContent,
                characters: countJapaneseCharacters(chapterText),
            }

            // Update the novel data with the new chapter and store it in localStorage
            currentNovelData.readChapters = { ...currentNovelData.readChapters, [chapterId]: newChapter };
            localStorage.setItem(id, JSON.stringify(currentNovelData));

            return currentNovelData;
        }
    }

    class SyosetuCom extends SiteStrategy {
        // https://ncode.syosetu.com/{novel}/{chapter}/
        // split by /
        // 1 -> {novel}
        // 2 -> {chapter}
        isInNovelPage() {
            return window.location.hostname === "ncode.syosetu.com";
        }

        getNovelId() {
            return window.location.pathname.split("/")[1];
        }

        handleOldNovel(id) {
            // get the current chapter from the URL (if any)
            let chapterId = window.location.pathname.split("/")[2];
            const currentNovelData = JSON.parse(localStorage.getItem(id));

            // if we are not in a chapter page, just return the existing novel data
            if (!chapterId) {
                return currentNovelData;
            }

            // Get the chapter content and calculate the character count
            const chapterContent = document.querySelector(".p-novel__text");
            const chapterText = [...chapterContent.childNodes].map((node) => node.textContent).join("");

            // in mobile mode, the title uses the class p-novel__subtitle-episode instead
            let title = document.querySelector(".p-novel__title")?.textContent ?? null
            if (!title) {
                title = document.querySelector(".p-novel__subtitle-episode").textContent
            }
            const newChapter = {
                title,
                characters: countJapaneseCharacters(chapterText),
            }

            // Update the novel data with the new chapter and store it in localStorage
            currentNovelData.readChapters = { ...currentNovelData.readChapters, [chapterId]: newChapter };
            localStorage.setItem(id, JSON.stringify(currentNovelData));

            return currentNovelData;
        }
    }

    /**
     * @param {string} hostname 
     * @returns {SiteStrategy}
     */
    function getHandlerByHost(hostname) {
        if (hostname.endsWith("syosetu.org")) {
            return new SyosetuOrg();
        }
        else if (hostname.endsWith("syosetu.com")) {
            return new SyosetuCom();
        }
        else if (hostname.endsWith("kakuyomu.jp")) {
            return new KakuyomuJp();
        }
        throw new Error("Site not supported!");
    }

    function main() {
        const hostname = getHostname();
        const handler = getHandlerByHost(hostname);

        // if we're not currently in a novel-related page where we can get the id, we do nothing
        // i.e in home page or settings, etc
        if (!handler.isInNovelPage()) {
            return;
        }

        const novelId = handler.getNovelId();
        if (localStorage.getItem(novelId) === null) {
            initializeStorage(novelId);
        }

        const currentNovel = handler.handleOldNovel(novelId);
        handler.renderCounter(novelId, currentNovel);
    }

    main();
})();