// ==UserScript==
// @name Narou API Info (in box)
// @namespace haaarug
// @version 2.7
// @description なろうの小説トップページにAPIで取得した作品情報を表示、キーワード強調、30分間同一タブ内キャッシュ保存、作者ページリンク
// @license CC0
// @match https://ncode.syosetu.com/*
// @grant GM_xmlhttpRequest
// @connect api.syosetu.com
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
const NGwords = ["残酷", "NG2", "NG3", "NG4", "NG5"];
const OKwords = ["異世界", "OK2", "OK3", "OK4", "OK5"];
//同一タブ内キャッシュ保持時間
const TTL_MINUTES = 30;
// 話数ページではなく作品トップページかを確認
const pathSegments = location.pathname.split('/').filter(Boolean);
if (pathSegments.length !== 1) return;
const match = pathSegments[0].match(/^(n\d+[a-z]+)$/i);
if (!match) return;
const ncode = match[1].toLowerCase();
const apiUrl = `https://api.syosetu.com/novelapi/api/?out=json&ncode=${encodeURIComponent(ncode)}`;
const cacheKey = `novelInfo_${ncode}`;
function getCachedData() {
const raw = sessionStorage.getItem(cacheKey);
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
const now = Date.now();
if (now - parsed.timestamp < TTL_MINUTES * 60 * 1000) {
return parsed.data;
} else {
sessionStorage.removeItem(cacheKey);
return null;
}
} catch {
sessionStorage.removeItem(cacheKey);
return null;
}
}
function saveToCache(data) {
sessionStorage.setItem(cacheKey, JSON.stringify({
timestamp: Date.now(),
data
}));
}
// 情報表示ボックス
function createInfoBox(data, source = "API") {
const title = data.title || '不明';
const writer = data.writer || '不明';
const status = data.end === 0 ? '完結' : '連載中❌';
const eternal = data.isstop === 0 ? '' : '⚠️エタ?⚠️';
const keywords = data.keyword || '不明';
const highlightedKeywords = keywords.split(" ").map(word => {
if (NGwords.some(ng => word.includes(ng))) {
return `<span style="color: red; font-weight: bold; font-size: 22px;">${word}</span>`;
} else if (OKwords.some(ok => word.includes(ok))) {
return `<span style="color: green;">${word}</span>`;
} else {
return `${word}`;
}
}).join(" ");
const length = data.length ? data.length.toLocaleString() + '文字' : '不明';
const general_lastup = data.general_lastup || '不明';
const general_all_no = data.general_all_no ? data.general_all_no.toLocaleString() + '話' : '不明';
const genreMap = {
0: '未選択〔未選択〕', 101: '異世界〔恋愛〕', 102: '現実世界〔恋愛〕',
201: 'ハイファンタジー〔ファンタジー〕', 202: 'ローファンタジー〔ファンタジー〕',
301: '純文学〔文芸〕', 302: 'ヒューマンドラマ〔文芸〕', 303: '歴史〔文芸〕',
304: '推理〔文芸〕', 305: 'ホラー〔文芸〕', 306: 'アクション〔文芸〕',
307: 'コメディー〔文芸〕', 401: 'VRゲーム〔SF〕', 402: '宇宙〔SF〕',
403: '空想科学〔SF〕', 404: 'パニック〔SF〕',
9901: '童話〔その他〕', 9902: '詩〔その他〕', 9903: 'エッセイ〔その他〕',
9904: 'リプレイ〔その他〕', 9999: 'その他〔その他〕', 9801: 'ノンジャンル〔ノンジャンル〕'
};
const genreText = genreMap[data.genre] || '不明ジャンル';
// 更新ボタン
const refreshButtonHTML = `
<button id="refresh-button" style="
font-size: 13px;
margin-left: 10px;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #888;
cursor: pointer;
">🔄 再取得</button>
`;
// 作者マイページリンク
const userid = data.userid;
const authorPageUrl = userid ? `https://mypage.syosetu.com/${userid}/` : null;
const infoBox = document.createElement('div');
infoBox.id = "novel-info-box";
infoBox.style.cssText = `
background-color: #f5f5f5;
border: 1px solid #ccc;
width: 333px;
height: auto;
position: fixed;
top: 50px;
left: 0px;
z-index: 9999;
font-size: 18px;
line-height: 1.6;
color: #333;
padding: 15px;
overflow-y: auto;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
border-radius: 8px;
`;
infoBox.innerHTML = `
<strong>📚</strong> ${title}<br>
<strong>🖋️</strong> <a href="${authorPageUrl}" target="_blank" style="text-decoration: underline;">${writer}</a><br>
<div style="height: 10px;"></div>
<strong>📝</strong> ${genreText}<br>
<strong>🔑</strong> ${highlightedKeywords}<br>
<div style="height: 10px;"></div>
<strong>🔤 文字数:</strong> ${length}<br>
<strong>📖 全</strong> ${general_all_no}<br>
<strong>📅 最新掲載日:</strong> ${general_lastup}<br>
<strong>✍️ </strong> ${status} <strong style="color: red;"> ${eternal}</strong><br>
<small style="color: gray;">[取得: ${source}]</small> ${refreshButtonHTML}
`;
// 再取得ボタンにイベント追加
setTimeout(() => {
const refreshBtn = document.getElementById("refresh-button");
if (refreshBtn) {
refreshBtn.onclick = () => fetchFromAPI(true);
}
}, 0);
return infoBox;
}
// 開閉ボタン
function insertControls(infoBox) {
const toggleButton = document.createElement('button');
toggleButton.textContent = 'ℹ️';
toggleButton.style.cssText = `
position: fixed;
top: 10px;
left: 10px;
z-index: 10000;
padding: 5px;
font-size: 14px;
border-radius: 5px;
border: 1px solid #888;
background: #f0f0f0;
cursor: pointer;
`;
toggleButton.onclick = () => {
infoBox.style.display = infoBox.style.display === 'none' ? 'block' : 'none';
};
document.body.appendChild(toggleButton);
}
// APIリクエスト
function fetchFromAPI(force = false) {
// キャッシュが存在し、手動でなければ再取得不要
if (!force && getCachedData()) {
return;
}
GM_xmlhttpRequest({
method: 'GET',
url: apiUrl,
headers: { 'Accept': 'application/json' },
onload: function (response) {
try {
const json = JSON.parse(response.responseText);
if (json.length < 2) return;
const data = json[1];
saveToCache(data);
const oldBox = document.getElementById('novel-info-box');
if (oldBox) oldBox.remove();
const box = createInfoBox(data, force ? "API(手動)" : "API");
document.body.appendChild(box);
} catch (e) {
console.error('JSON解析エラー:', e);
}
},
onerror: function (err) {
console.error('API通信エラー:', err);
}
});
}
// メイン処理
const cached = getCachedData();
const box = createInfoBox(cached || {}, cached ? "キャッシュ" : "API");
document.body.appendChild(box);
insertControls(box);
if (!cached) {
fetchFromAPI();
}
})();