Greasy Fork镜像 支持简体中文。

Japanese Reading Tracker

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

  1. // ==UserScript==
  2. // @name Japanese Reading Tracker
  3. // @description Keeps track of characters read in popular japanese websites like syosetu.com, etc.
  4. // @version 1.3.1
  5. // @author nenlitiochristian
  6. // @match https://syosetu.org/*
  7. // @match https://kakuyomu.jp/*
  8. // @match https://ncode.syosetu.com/*
  9. // @license MIT
  10. // @namespace JP_reading_tracker_nc
  11. // ==/UserScript==
  12.  
  13. (function () {
  14. 'use strict';
  15. // credit to cademcniven for this
  16. function countJapaneseCharacters(japaneseText) {
  17. const regex = /[一-龠]+|[ぁ-ゔ]+|[ァ-ヴー]+|[a-zA-Z0-9]+|[々〆〤ヶ]+/g
  18. return [...japaneseText.matchAll(regex)].join('').length
  19. }
  20.  
  21. /**
  22. * @typedef {Object} Chapter
  23. * @property {string} title - The title of the chapter.
  24. * @property {number} characters - The number of characters read in the chapter.
  25. */
  26.  
  27. /**
  28. * @typedef {Object} Novel
  29. * @property {Object.<string, Chapter>} readChapters - A map where the key is the chapter ID and the value is a `Chapter` object.
  30. */
  31.  
  32. /**
  33. * Makes a new empty novel
  34. * @returns {Novel}
  35. */
  36. function newNovel() {
  37. return {
  38. readChapters: {},
  39. }
  40. }
  41.  
  42. /**
  43. * @param {string} id - The unique identifier for the novel.
  44. */
  45. function initializeStorage(id) {
  46. localStorage.setItem(id, JSON.stringify(newNovel()));
  47. }
  48.  
  49. /**
  50. * @param {Novel} novel
  51. * @returns {number}
  52. */
  53. function countTotalCharacters(novel) {
  54. let counter = 0;
  55. // Sum up the character count from all chapters
  56. Object.entries(novel.readChapters).forEach(([_, value]) => {
  57. counter += value.characters;
  58. });
  59. return counter;
  60. }
  61.  
  62. /**
  63. * @param {Novel} novel
  64. * @returns {string}
  65. */
  66. function exportCSV(novel) {
  67. let string = "";
  68. Object.entries(novel.readChapters).forEach(([key, value]) => {
  69. string += `${key},${value.title},${value.characters}\n`
  70. });
  71. }
  72.  
  73. /**
  74. * @returns {string}
  75. */
  76. function getHostname() {
  77. return window.location.hostname;
  78. }
  79.  
  80. class SiteStrategy {
  81. isInNovelPage() {
  82. throw new Error("Method not implemented.");
  83. }
  84. getNovelId() {
  85. throw new Error("Method not implemented.");
  86. }
  87. handleOldNovel(id) {
  88. throw new Error("Method not implemented.");
  89. }
  90.  
  91. /**
  92. * @param {string} id
  93. * @param {Novel} novelData
  94. */
  95. renderCounter(id, novelData) {
  96. // inject styles
  97. 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; }
  98. .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); }
  99. #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; }
  100. #tracker-popup h2 { border-bottom: 1px solid #444; padding-bottom: 10px; }
  101. .table-list { padding-top: 4px; margin-bottom: auto; width: 100%; display: block; overflow-y: auto; }
  102. .table-list th, .table-list td { padding: 5px; }
  103. .delete-button { background-color: #ff6347; color: #fff; border: none; padding: 5px; cursor: pointer; border-radius: 3px; }
  104. .close-button { background-color: #444; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; margin-top: 20px; width: fit-content; } `;
  105.  
  106. const styleSheet = document.createElement("style");
  107. styleSheet.innerText = styles;
  108. document.head.appendChild(styleSheet);
  109.  
  110. // add button to display the popup
  111. const button = document.createElement('button');
  112. button.id = 'tracker-button';
  113. button.textContent = `🍞`;
  114.  
  115. document.body.appendChild(button);
  116.  
  117. const overlayContainer = document.createElement('div');
  118. overlayContainer.classList.add('overlay-container');
  119.  
  120. const popup = document.createElement('div');
  121. popup.id = 'tracker-popup';
  122.  
  123. // Add content to the popup
  124. const title = document.createElement('h2');
  125. title.textContent = `合計文字数:${countTotalCharacters(novelData)}`;
  126. popup.appendChild(title);
  127.  
  128. // List of tracked chapters
  129. const chapterList = document.createElement('table');
  130. chapterList.classList.add('table-list');
  131.  
  132.  
  133.  
  134. const listHeader = document.createElement('thead');
  135. listHeader.innerHTML = `<tr>
  136. <th style="width:32px;">#</th> <th style="width:75%;">タイトル</th> <th>文字数</th> <th style="width:64px;"></th>
  137. </tr>`;
  138.  
  139. chapterList.append(listHeader);
  140.  
  141. const listBody = document.createElement('tbody');
  142. chapterList.append(listBody);
  143.  
  144. let index = 1;
  145. Object.entries(novelData.readChapters).sort((a, b) => parseInt(a) - parseInt(b)).forEach(([key, chapter]) => {
  146. const listItem = document.createElement('tr');
  147. listItem.innerHTML = `
  148. <td>${index}</td> <td style="width:auto;">${chapter.title}</td> <td>${chapter.characters}</td>
  149. <td>
  150. <button data-chapter="${key}" class="delete-button">削除</button>
  151. </td>`;
  152.  
  153. listItem.querySelector('button').addEventListener('click', () => {
  154. const { [key]: _, ...updatedChapters } = novelData.readChapters;
  155. novelData.readChapters = updatedChapters;
  156. localStorage.setItem(id, JSON.stringify(novelData)); // Update the novel data in localStorage
  157. window.location.reload(); // Reload to update UI
  158. });
  159.  
  160. listBody.appendChild(listItem);
  161. index++;
  162. });
  163.  
  164. popup.appendChild(chapterList);
  165.  
  166. // Add close button
  167. const closeButton = document.createElement('button');
  168. closeButton.textContent = '閉じる';
  169. closeButton.classList.add('close-button');
  170.  
  171. closeButton.addEventListener('click', () => {
  172. overlayContainer.style.display = 'none';
  173. });
  174.  
  175. popup.appendChild(closeButton);
  176.  
  177. overlayContainer.appendChild(popup);
  178. document.body.appendChild(overlayContainer);
  179.  
  180. button.addEventListener('click', () => {
  181. overlayContainer.style.display = overlayContainer.style.display === 'none' ? 'flex' : 'none';
  182. });
  183. }
  184.  
  185. }
  186.  
  187. class SyosetuOrg extends SiteStrategy {
  188. // https://syosetu.org/novel/{id}/{chapter}.html
  189. // split by "/"
  190. // 1 -> gets "novel"
  191. // 2 -> gets {id}
  192. // 3 -> gets {chapter}
  193. isInNovelPage() {
  194. return window.location.pathname.split("/")[1] === "novel";
  195. }
  196.  
  197. getNovelId() {
  198. return window.location.pathname.split("/")[2];
  199. }
  200.  
  201. handleOldNovel(id) {
  202. // get the current chapter from the URL (if any)
  203. let chapterId = window.location.pathname.split("/")[3];
  204. const currentNovelData = JSON.parse(localStorage.getItem(id));
  205.  
  206. // if we are not in a chapter page, just return the existing novel data
  207. if (!chapterId) {
  208. return currentNovelData;
  209. }
  210.  
  211. // syosetu.org has .html attached to the number, we remove it
  212. chapterId = chapterId.split(".")[0];
  213.  
  214. // Get the chapter content and calculate the character count
  215. const chapterContent = document.querySelector("#honbun");
  216. const chapterText = [...chapterContent.childNodes].map((node) => node.textContent).join("");
  217.  
  218. // Create a new chapter entry
  219. // syosetu.org has 2 utterly different html pages for desktop and mobile
  220. const titles = document.querySelectorAll('span[style="font-size:120%"]')
  221. let newChapter = {};
  222.  
  223. // if desktop
  224. if (titles.length === 2) {
  225. newChapter.title = titles[1].textContent ?? "Unknown"
  226. newChapter.characters = countJapaneseCharacters(chapterText)
  227. }
  228. // if mobile
  229. else {
  230. newChapter.title = document.querySelector("h2").textContent ?? "Unknown"
  231. newChapter.characters = countJapaneseCharacters(chapterText)
  232. }
  233.  
  234. // Update the novel data with the new chapter and store it in localStorage
  235. currentNovelData.readChapters = { ...currentNovelData.readChapters, [chapterId]: newChapter };
  236. localStorage.setItem(id, JSON.stringify(currentNovelData));
  237.  
  238. return currentNovelData;
  239. }
  240. }
  241.  
  242.  
  243. class KakuyomuJp extends SiteStrategy {
  244. // https://kakuyomu.jp/works/{novel}/episodes/{chapter}
  245. // split by /
  246. // 1 -> works
  247. // 2 -> {novel}
  248. // 4 -> {chapter}
  249. isInNovelPage() {
  250. return window.location.pathname.split("/")[1] === "works";
  251. }
  252.  
  253. getNovelId() {
  254. return window.location.pathname.split("/")[2];
  255. }
  256.  
  257. handleOldNovel(id) {
  258. // get the current chapter from the URL (if any)
  259. let chapterId = window.location.pathname.split("/")[4];
  260. const currentNovelData = JSON.parse(localStorage.getItem(id));
  261.  
  262. // if we are not in a chapter page, just return the existing novel data
  263. if (!chapterId) {
  264. return currentNovelData;
  265. }
  266.  
  267. // Get the chapter content and calculate the character count
  268. const chapterContent = document.querySelector(".widget-episodeBody");
  269. const chapterText = [...chapterContent.childNodes].map((node) => node.textContent).join("");
  270.  
  271. const newChapter = {
  272. title: document.querySelector(".widget-episodeTitle").textContent,
  273. characters: countJapaneseCharacters(chapterText),
  274. }
  275.  
  276. // Update the novel data with the new chapter and store it in localStorage
  277. currentNovelData.readChapters = { ...currentNovelData.readChapters, [chapterId]: newChapter };
  278. localStorage.setItem(id, JSON.stringify(currentNovelData));
  279.  
  280. return currentNovelData;
  281. }
  282. }
  283.  
  284. class SyosetuCom extends SiteStrategy {
  285. // https://ncode.syosetu.com/{novel}/{chapter}/
  286. // split by /
  287. // 1 -> {novel}
  288. // 2 -> {chapter}
  289. isInNovelPage() {
  290. return window.location.hostname === "ncode.syosetu.com";
  291. }
  292.  
  293. getNovelId() {
  294. return window.location.pathname.split("/")[1];
  295. }
  296.  
  297. handleOldNovel(id) {
  298. // get the current chapter from the URL (if any)
  299. let chapterId = window.location.pathname.split("/")[2];
  300. const currentNovelData = JSON.parse(localStorage.getItem(id));
  301.  
  302. // if we are not in a chapter page, just return the existing novel data
  303. if (!chapterId) {
  304. return currentNovelData;
  305. }
  306.  
  307. // Get the chapter content and calculate the character count
  308. const chapterContent = document.querySelector(".p-novel__text");
  309. const chapterText = [...chapterContent.childNodes].map((node) => node.textContent).join("");
  310.  
  311. // in mobile mode, the title uses the class p-novel__subtitle-episode instead
  312. let title = document.querySelector(".p-novel__title")?.textContent ?? null
  313. if (!title) {
  314. title = document.querySelector(".p-novel__subtitle-episode").textContent
  315. }
  316. const newChapter = {
  317. title,
  318. characters: countJapaneseCharacters(chapterText),
  319. }
  320.  
  321. // Update the novel data with the new chapter and store it in localStorage
  322. currentNovelData.readChapters = { ...currentNovelData.readChapters, [chapterId]: newChapter };
  323. localStorage.setItem(id, JSON.stringify(currentNovelData));
  324.  
  325. return currentNovelData;
  326. }
  327. }
  328.  
  329. /**
  330. * @param {string} hostname
  331. * @returns {SiteStrategy}
  332. */
  333. function getHandlerByHost(hostname) {
  334. if (hostname.endsWith("syosetu.org")) {
  335. return new SyosetuOrg();
  336. }
  337. else if (hostname.endsWith("syosetu.com")) {
  338. return new SyosetuCom();
  339. }
  340. else if (hostname.endsWith("kakuyomu.jp")) {
  341. return new KakuyomuJp();
  342. }
  343. throw new Error("Site not supported!");
  344. }
  345.  
  346. function main() {
  347. const hostname = getHostname();
  348. const handler = getHandlerByHost(hostname);
  349.  
  350. // if we're not currently in a novel-related page where we can get the id, we do nothing
  351. // i.e in home page or settings, etc
  352. if (!handler.isInNovelPage()) {
  353. return;
  354. }
  355.  
  356. const novelId = handler.getNovelId();
  357. if (localStorage.getItem(novelId) === null) {
  358. initializeStorage(novelId);
  359. }
  360.  
  361. const currentNovel = handler.handleOldNovel(novelId);
  362. handler.renderCounter(novelId, currentNovel);
  363. }
  364.  
  365. main();
  366. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址