Narou API Info (in box)

なろうの小説トップページになろう小説APIで取得した作品情報を表示、キーワード強調、30分間同一タブ内キャッシュ保存、作者ページリンク

  1. // ==UserScript==
  2. // @name Narou API Info (in box)
  3. // @namespace haaarug
  4. // @version 2.8
  5. // @description なろうの小説トップページになろう小説APIで取得した作品情報を表示、キーワード強調、30分間同一タブ内キャッシュ保存、作者ページリンク
  6. // @license CC0
  7. // @match https://ncode.syosetu.com/*
  8. // @grant GM_xmlhttpRequest
  9. // @connect api.syosetu.com
  10. // @run-at document-end
  11. // ==/UserScript==
  12.  
  13. (function () {
  14. 'use strict';
  15. const NGwords = ["残酷", "NG2", "NG3", "NG4", "NG5"];
  16. const OKwords = ["異世界", "OK2", "OK3", "OK4", "OK5"];
  17.  
  18. //同一タブ内キャッシュ保持時間
  19. const TTL_MINUTES = 30;
  20.  
  21. // 話数ページではなく作品トップページかを確認
  22. const pathSegments = location.pathname.split('/').filter(Boolean);
  23. if (pathSegments.length !== 1) return;
  24.  
  25. const match = pathSegments[0].match(/^(n\d+[a-z]+)$/i);
  26. if (!match) return;
  27.  
  28. const ncode = match[1].toLowerCase();
  29. const apiUrl = `https://api.syosetu.com/novelapi/api/?out=json&of=e-i-k-l-gl-ga-g&ncode=${encodeURIComponent(ncode)}`;
  30. const cacheKey = `novelInfo_${ncode}`;
  31.  
  32. function getCachedData() {
  33. const raw = sessionStorage.getItem(cacheKey);
  34. if (!raw) return null;
  35.  
  36. try {
  37. const parsed = JSON.parse(raw);
  38. const now = Date.now();
  39. if (now - parsed.timestamp < TTL_MINUTES * 60 * 1000) {
  40. return parsed.data;
  41. } else {
  42. sessionStorage.removeItem(cacheKey);
  43. return null;
  44. }
  45. } catch {
  46. sessionStorage.removeItem(cacheKey);
  47. return null;
  48. }
  49. }
  50.  
  51. function saveToCache(data) {
  52. sessionStorage.setItem(cacheKey, JSON.stringify({
  53. timestamp: Date.now(),
  54. data
  55. }));
  56. }
  57.  
  58. // タイトルの取得
  59. const titleElement = document.querySelector('h1.p-novel__title');
  60. const title = titleElement ? titleElement.textContent.trim() : '不明';
  61. // 作者名の取得
  62. const authorElement = document.querySelector('div.p-novel__author a');
  63. const writer = authorElement ? authorElement.textContent.trim() : '不明';
  64. // 作者マイページの取得
  65. const authorLinkElement = document.querySelector('.p-novel__author a');
  66. const authorPageUrl = authorLinkElement ? authorLinkElement.href : 'URL不明';
  67.  
  68. // 情報表示ボックス
  69. function createInfoBox(data, source = "API") {
  70. const allcount = data.allcount || '不明';
  71. const status = data.end === 0 ? '完結' : '連載中❌';
  72. const eternal = data.isstop === 0 ? '' : '⚠️エタ?⚠️';
  73. const keywords = data.keyword || '不明';
  74. const highlightedKeywords = keywords.split(" ").map(word => {
  75. if (NGwords.some(ng => word.includes(ng))) {
  76. return `<span style="color: red; font-weight: bold; font-size: 22px;">${word}</span>`;
  77. } else if (OKwords.some(ok => word.includes(ok))) {
  78. return `<span style="color: green;">${word}</span>`;
  79. } else {
  80. return `${word}`;
  81. }
  82. }).join(" ");
  83.  
  84. const length = data.length ? data.length.toLocaleString() + '文字' : '不明';
  85. const general_lastup = data.general_lastup || '不明';
  86. const general_all_no = data.general_all_no ? data.general_all_no.toLocaleString() + '話' : '不明';
  87.  
  88. const genreMap = {
  89. 0: '未選択〔未選択〕', 101: '異世界〔恋愛〕', 102: '現実世界〔恋愛〕',
  90. 201: 'ハイファンタジー〔ファンタジー〕', 202: 'ローファンタジー〔ファンタジー〕',
  91. 301: '純文学〔文芸〕', 302: 'ヒューマンドラマ〔文芸〕', 303: '歴史〔文芸〕',
  92. 304: '推理〔文芸〕', 305: 'ホラー〔文芸〕', 306: 'アクション〔文芸〕',
  93. 307: 'コメディー〔文芸〕', 401: 'VRゲーム〔SF〕', 402: '宇宙〔SF〕',
  94. 403: '空想科学〔SF〕', 404: 'パニック〔SF〕',
  95. 9901: '童話〔その他〕', 9902: '詩〔その他〕', 9903: 'エッセイ〔その他〕',
  96. 9904: 'リプレイ〔その他〕', 9999: 'その他〔その他〕', 9801: 'ノンジャンル〔ノンジャンル〕'
  97. };
  98.  
  99. const genreText = genreMap[data.genre] || '不明ジャンル';
  100.  
  101. // 更新ボタン
  102. const refreshButtonHTML = `
  103. <button id="refresh-button" style="
  104. font-size: 13px;
  105. margin-left: 10px;
  106. padding: 2px 6px;
  107. border-radius: 4px;
  108. border: 1px solid #888;
  109. cursor: pointer;
  110. ">🔄 再取得</button>
  111. `;
  112.  
  113. //評価をつけた作品一覧
  114. const hyoukaUrl = title ? `https://www.google.com/search?q=${encodeURIComponent(`site:mypage.syosetu.com/mypagenovelhyoka/list "${title}"`)}` : null;
  115. //ブックマーク一覧
  116. const bookmarkUrl = title ? `https://www.google.com/search?q=${encodeURIComponent(`site:mypage.syosetu.com/mypagefavnovelmain/list "${title}"`)}` : null;
  117.  
  118. const infoBox = document.createElement('div');
  119. infoBox.id = "novel-info-box";
  120. infoBox.style.cssText = `
  121. background-color: #f5f5f5;
  122. border: 1px solid #ccc;
  123. width: 333px;
  124. height: auto;
  125. position: fixed;
  126. top: 50px;
  127. left: 0px;
  128. z-index: 9999;
  129. font-size: 18px;
  130. line-height: 1.6;
  131. color: #333;
  132. padding: 15px;
  133. overflow-y: auto;
  134. box-shadow: 0 4px 8px rgba(0,0,0,0.2);
  135. border-radius: 8px;
  136. `;
  137.  
  138. if (data.allcount === 0) {
  139. infoBox.innerHTML = `
  140. <strong>📚</strong> ${title}<br>
  141. <strong>🖋️</strong> <a href="${authorPageUrl}" target="_blank" style="text-decoration: underline;">${writer}</a><br>
  142. <div style="height: 10px;"></div>
  143. <strong style="color: red;">取得失敗。</strong><br>
  144. <small>開示設定:検索除外中 の場合取得できません。</small><br>
  145. <div style="height: 10px;"></div>
  146. <small>🔖 <a href="${bookmarkUrl}" target="_blank" style="text-decoration: underline;">ブクマされている作品一覧ページ🔍</a></small><br>
  147. <small>🩷 <a href="${hyoukaUrl}" target="_blank" style="text-decoration: underline;">評価されている作品一覧ページ🔍</a></small><br>
  148. `;
  149. } else {
  150. infoBox.innerHTML = `
  151. <strong>📚</strong> ${title}<br>
  152. <strong>🖋️</strong> <a href="${authorPageUrl}" target="_blank" style="text-decoration: underline;">${writer}</a><br>
  153. <div style="height: 10px;"></div>
  154. <strong>📝</strong> ${genreText}<br>
  155. <strong>🔑</strong> ${highlightedKeywords}<br>
  156. <div style="height: 10px;"></div>
  157. <strong>🔤 文字数:</strong> ${length}<br>
  158. <strong>📖 全</strong> ${general_all_no}<br>
  159. <strong>📅 最新掲載日:</strong> ${general_lastup}<br>
  160. <strong>✍️ </strong> ${status} <strong style="color: red;"> ${eternal}</strong><br>
  161. <small style="color: gray;">[取得: ${source}]</small> ${refreshButtonHTML}<br>
  162. <div style="height: 10px;"></div>
  163. <small>🔖 <a href="${bookmarkUrl}" target="_blank" style="text-decoration: underline;">ブクマされている作品一覧ページ🔍</a></small><br>
  164. <small>🩷 <a href="${hyoukaUrl}" target="_blank" style="text-decoration: underline;">評価されている作品一覧ページ🔍</a></small><br>
  165. `;
  166. }
  167.  
  168. // 再取得ボタンにイベント追加
  169. setTimeout(() => {
  170. const refreshBtn = document.getElementById("refresh-button");
  171. if (refreshBtn) {
  172. refreshBtn.onclick = () => fetchFromAPI(true);
  173. }
  174. }, 0);
  175.  
  176. return infoBox;
  177. }
  178.  
  179. // 開閉ボタン
  180. function insertControls(infoBox) {
  181. const toggleButton = document.createElement('button');
  182. toggleButton.textContent = 'ℹ️';
  183. toggleButton.style.cssText = `
  184. position: fixed;
  185. top: 10px;
  186. left: 10px;
  187. z-index: 10000;
  188. padding: 5px;
  189. font-size: 14px;
  190. border-radius: 5px;
  191. border: 1px solid #888;
  192. background: #f0f0f0;
  193. cursor: pointer;
  194. `;
  195. toggleButton.onclick = () => {
  196. infoBox.style.display = infoBox.style.display === 'none' ? 'block' : 'none';
  197. };
  198.  
  199. document.body.appendChild(toggleButton);
  200. }
  201.  
  202. // APIリクエスト
  203. function fetchFromAPI(force = false) {
  204. // キャッシュが存在し、手動でなければ再取得不要
  205. if (!force && getCachedData()) {
  206. return;
  207. }
  208.  
  209. GM_xmlhttpRequest({
  210. method: 'GET',
  211. url: apiUrl,
  212. headers: { 'Accept': 'application/json' },
  213. onload: function (response) {
  214. try {
  215. const json = JSON.parse(response.responseText);
  216.  
  217. const data = Object.assign({}, json[0], json[1]);
  218. saveToCache(data);
  219.  
  220. const oldBox = document.getElementById('novel-info-box');
  221. if (oldBox) oldBox.remove();
  222.  
  223. const box = createInfoBox(data, force ? "API(手動)" : "API");
  224. document.body.appendChild(box);
  225. } catch (e) {
  226. console.error('JSON解析エラー:', e);
  227. }
  228. },
  229. onerror: function (err) {
  230. console.error('API通信エラー:', err);
  231. }
  232. });
  233. }
  234.  
  235. // メイン処理
  236. const cached = getCachedData();
  237. const box = createInfoBox(cached || {}, cached ? "キャッシュ" : "API");
  238. document.body.appendChild(box);
  239. insertControls(box);
  240. if (!cached) {
  241. fetchFromAPI();
  242. }
  243.  
  244. })();

QingJ © 2025

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