Narou API Info (in box)

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

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

})();

QingJ © 2025

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