// ==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();
})();