Web小説 メモサポーター

Web小説に登場する固有の単語にルビやノート、文字色を追加する事で、作品の理解を助けます。設定は作品ごとに個別適用されます。

// ==UserScript==
// @name         Web小説 メモサポーター
// @namespace    https://mypage.syosetu.com/348820/
// @version      1.0.2
// @description  Web小説に登場する固有の単語にルビやノート、文字色を追加する事で、作品の理解を助けます。設定は作品ごとに個別適用されます。
// @author       hikoyuki (ChatGPT)
// @license      MIT
// @match        https://ncode.syosetu.com/*/*
// @match        https://novel18.syosetu.com/*/*
// @match        https://syosetu.org/novel/*/*
// @match        https://kakuyomu.jp/works/*/episodes/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_listValues
// @grant        GM_deleteValue
// ==/UserScript==

;(async function() {
    'use strict';

    //
    // 1. サイト判定&識別子取得
    //
    const host = location.hostname;
    const parts = location.pathname.split('/').filter(s => s);
    let identifier = '';
    if (host === 'ncode.syosetu.com'    && parts.length >= 2) identifier = parts[0];
    else if (host === 'novel18.syosetu.com' && parts.length >= 1) identifier = parts[0];
    else if (host === 'syosetu.org' && parts[0] === 'novel' && parts.length >= 3) identifier = parts[1];
    else if (host === 'kakuyomu.jp' && parts[0] === 'works' && parts.length >= 4) identifier = parts[1];
    if (!identifier) identifier = host.replace(/\./g, '_');

    //
    // 2. ストレージキー設定
    //
    const prefixColor    = `color_${host}_${identifier}_`;
    const prefixRuby     = `ruby_${host}_${identifier}_`;
    const prefixConvert  = `conv_${host}_${identifier}_`;
    const prefixNote     = `note_${host}_${identifier}_`;
    const prefixBM       = `bm_${host}_${identifier}_`;
    const posLocalKey    = `pos_${host}_${identifier}`;

    //
    // 3. ストレージ操作関数
    //
    async function getEntries(prefix) {
        const arr = [];
        for (const k of await GM_listValues()) {
            if (k.startsWith(prefix)) {
                arr.push({ key: k, val: await GM_getValue(k) });
            }
        }
        return arr;
    }
    async function addEntry(prefix, obj) {
        const key = prefix + Date.now() + '_' + Math.random();
        await GM_setValue(key, obj);
    }
    async function resetAll() {
        for (const k of await GM_listValues()) {
            if (
                k.startsWith(prefixColor) ||
                k.startsWith(prefixRuby) ||
                k.startsWith(prefixConvert) ||
                k.startsWith(prefixNote) ||
                k.startsWith(prefixBM) ||
                k === posLocalKey
            ) {
                await GM_deleteValue(k);
            }
        }
    }
    async function deleteEntryByText(prefix, sel) {
        for (const e of await getEntries(prefix)) {
            const v = e.val;
            if (
                (prefix === prefixColor   && v.text === sel) ||
                (prefix === prefixRuby    && (v.text === sel || v.ruby === sel)) ||
                (prefix === prefixConvert && (v.text === sel || v.replace === sel)) ||
                (prefix === prefixNote    && (v.text === sel || v.note === sel)) ||
                (prefix === prefixBM      && v.text === sel)
            ) {
                await GM_deleteValue(e.key);
            }
        }
    }

    //
    // 4. DOM置換ユーティリティ
    //
    function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
    function applyColor(text, color, root = document.body) {
        const re = new RegExp(escapeRegExp(text), 'g');
        const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
            acceptNode: node => re.test(node.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
        });
        const nodes = [];
        while (walker.nextNode()) nodes.push(walker.currentNode);
        for (const node of nodes) {
            const frag = document.createDocumentFragment();
            let last = 0, m;
            while ((m = re.exec(node.nodeValue)) !== null) {
                frag.appendChild(document.createTextNode(node.nodeValue.slice(last, m.index)));
                const span = document.createElement('span');
                span.textContent = m[0];
                span.style.color = color;
                frag.appendChild(span);
                last = m.index + m[0].length;
            }
            frag.appendChild(document.createTextNode(node.nodeValue.slice(last)));
            node.parentNode.replaceChild(frag, node);
        }
    }
    function applyRuby(text, ruby, root = document.body) {
        const re = new RegExp(escapeRegExp(text), 'g');
        const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
            acceptNode: node => re.test(node.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
        });
        const nodes = [];
        while (walker.nextNode()) nodes.push(walker.currentNode);
        for (const node of nodes) {
            const frag = document.createDocumentFragment();
            let last = 0, m;
            while ((m = re.exec(node.nodeValue)) !== null) {
                frag.appendChild(document.createTextNode(node.nodeValue.slice(last, m.index)));
                const r = document.createElement('ruby'),
                      rb = document.createElement('rb'),
                      rt = document.createElement('rt');
                rb.textContent = m[0];
                rt.textContent = ruby;
                r.appendChild(rb);
                r.appendChild(rt);
                frag.appendChild(r);
                last = m.index + m[0].length;
            }
            frag.appendChild(document.createTextNode(node.nodeValue.slice(last)));
            node.parentNode.replaceChild(frag, node);
        }
    }
    function applyConvert(oldText, newText, root = document.body) {
        const re = new RegExp(escapeRegExp(oldText), 'g');
        const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
            acceptNode: node => re.test(node.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
        });
        const nodes = [];
        while (walker.nextNode()) nodes.push(walker.currentNode);
        for (const node of nodes) {
            const frag = document.createDocumentFragment();
            let last = 0, m;
            while ((m = re.exec(node.nodeValue)) !== null) {
                frag.appendChild(document.createTextNode(node.nodeValue.slice(last, m.index)));
                frag.appendChild(document.createTextNode(newText));
                last = m.index + oldText.length;
            }
            frag.appendChild(document.createTextNode(node.nodeValue.slice(last)));
            node.parentNode.replaceChild(frag, node);
        }
    }
    function applyNote(text, note, root = document.body) {
        const re = new RegExp(escapeRegExp(text), 'g');
        const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
            acceptNode: node => re.test(node.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
        });
        const nodes = [];
        while (walker.nextNode()) nodes.push(walker.currentNode);
        for (const node of nodes) {
            const frag = document.createDocumentFragment();
            let last = 0, m;
            while ((m = re.exec(node.nodeValue)) !== null) {
                frag.appendChild(document.createTextNode(node.nodeValue.slice(last, m.index)));
                const span = document.createElement('span');
                span.textContent = m[0];
                span.className = 'annotation';
                span.dataset.note = note;
                frag.appendChild(span);
                last = m.index + m[0].length;
            }
            frag.appendChild(document.createTextNode(node.nodeValue.slice(last)));
            node.parentNode.replaceChild(frag, node);
        }
    }
    function applyBookmark(text, bm, root = document.body) {
        const re = new RegExp(escapeRegExp(text), 'g');
        const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
            acceptNode: node => re.test(node.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
        });
        const nodes = [];
        while (walker.nextNode()) nodes.push(walker.currentNode);
        for (const node of nodes) {
            const frag = document.createDocumentFragment();
            let last = 0, m;
            while ((m = re.exec(node.nodeValue)) !== null) {
                frag.appendChild(document.createTextNode(node.nodeValue.slice(last, m.index)));
                const span = document.createElement('span');
                span.textContent = m[0];
                span.className = 'bookmark';
                frag.appendChild(span);
                last = m.index + m[0].length;
            }
            frag.appendChild(document.createTextNode(node.nodeValue.slice(last)));
            node.parentNode.replaceChild(frag, node);
        }
    }

    //
    // 5. 初期描画 - ページ読み込み時に保存済み設定を再適用
    //
    (await getEntries(prefixColor)).forEach(e => applyColor(e.val.text, e.val.color));
    (await getEntries(prefixRuby)).forEach(e => applyRuby(e.val.text, e.val.ruby));
    (await getEntries(prefixConvert)).forEach(e => applyConvert(e.val.text, e.val.replace));
    (await getEntries(prefixNote)).forEach(e => applyNote(e.val.text, e.val.note));
    (await getEntries(prefixBM)).forEach(e => applyBookmark(e.val.text, e.val));

    //
    // 6. 注釈ポップアップ制御
    //
    function closePopup() {
        const p = document.getElementById('annotation-popup');
        if (p) p.remove();
    }
    document.addEventListener('click', e => {
        const p = document.getElementById('annotation-popup');
        if (p && !p.contains(e.target) && !e.target.classList.contains('annotation')) {
            closePopup();
        }
    });
    document.body.addEventListener('click', e => {
        if (e.target.classList.contains('annotation')) {
            e.stopPropagation();
            closePopup();
            const note = e.target.dataset.note;
            const rect = e.target.getBoundingClientRect();
            const pop = document.createElement('div');
            pop.id = 'annotation-popup';
            Object.assign(pop.style, {
                position:   'absolute',
                top:        `${rect.top + window.scrollY - 8}px`,
                left:       `${rect.left + window.scrollX}px`,
                transform:  'translateY(-100%)',
                background: '#fff',
                border:     '1px solid #ccc',
                padding:    '4px',
                borderRadius: '4px',
                zIndex:     2147483647,
                maxWidth:   '200px'
            });
            pop.textContent = note;
            document.body.appendChild(pop);
        }
    });

    //
    // 7. インポート/エクスポート機能
    //
    async function exportAll() {
        const data = {};
        for (const k of await GM_listValues()) {
            data[k] = await GM_getValue(k);
        }
        const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = 'WebNovelReader.json';
        document.body.appendChild(a);
        a.click();
        a.remove();
        setTimeout(() => URL.revokeObjectURL(url), 1000);
    }
    async function importAll() {
        return new Promise(resolve => {
            const input = document.createElement('input');
            input.type = 'file';
            input.accept = 'application/json';
            input.onchange = function() {
                const reader = new FileReader();
                reader.onload = async () => {
                    try {
                        const obj = JSON.parse(reader.result);
                        for (const k in obj) {
                            await GM_setValue(k, obj[k]);
                        }
                        location.reload();
                    } catch (e) {
                        alert('インポート失敗: ' + e);
                    }
                    resolve();
                };
                reader.readAsText(input.files[0]);
            };
            input.click();
        });
    }

    //
    // 8. 設定管理UI
    //
    async function openSettingsManager() {
        const overlay = document.createElement('div');
        Object.assign(overlay.style, {
            position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
            background: 'rgba(0,0,0,0.5)', zIndex: 2147483647
        });
        document.body.appendChild(overlay);

        const panel = document.createElement('div');
        Object.assign(panel.style, {
            position: 'fixed', top: '50%', left: '50%',
            transform: 'translate(-50%,-50%)',
            width: '80%', maxHeight: '80%', overflowY: 'auto',
            background: '#fff', padding: '16px',
            borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.3)'
        });
        overlay.appendChild(panel);

        const closeBtn = document.createElement('button');
        closeBtn.textContent = '✕';
        Object.assign(closeBtn.style, {
            position: 'absolute', top: '8px', right: '8px',
            background: 'transparent', border: 'none',
            fontSize: '18px', cursor: 'pointer'
        });
        panel.appendChild(closeBtn);
        closeBtn.addEventListener('click', () => overlay.remove());

        const table = document.createElement('table');
        table.style.width = '100%';
        table.innerHTML = '<tr><th>種別</th><th>テキスト</th><th>値</th><th>操作</th></tr>';
        panel.appendChild(table);

        const prefixes = [
            { key: prefixColor,    name: 'カラー' },
            { key: prefixRuby,     name: 'Ruby' },
            { key: prefixConvert,  name: 'Convert' },
            { key: prefixNote,     name: 'Note' },
            { key: prefixBM,       name: 'Bookmark' }
        ];
        for (const pr of prefixes) {
            for (const e of await getEntries(pr.key)) {
                const tr = document.createElement('tr');
                tr.innerHTML = `
                  <td>${pr.name}</td>
                  <td>${e.val.text}</td>
                  <td>${JSON.stringify(e.val)}</td>
                  <td>
                    <button class="edit">編集</button>
                    <button class="del" style="margin-left:8px">削除</button>
                  </td>`;
                table.appendChild(tr);

                tr.querySelector('.edit').addEventListener('click', async () => {
                    const input = prompt('新しい設定値(JSON):', JSON.stringify(e.val));
                    if (!input) return;
                    try {
                        const nv = JSON.parse(input);
                        await GM_setValue(e.key, nv);
                        tr.cells[2].textContent = input;
                    } catch {
                        alert('JSON形式が不正です');
                    }
                });
                tr.querySelector('.del').addEventListener('click', async () => {
                    if (!confirm('本当に削除しますか?')) return;
                    await GM_deleteValue(e.key);
                    tr.remove();
                });
            }
        }
    }

    //
    // 9. ブックマーク一覧表示
    //
    async function openBookmarkList() {
        const overlay = document.createElement('div');
        Object.assign(overlay.style, {
            position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
            background: 'rgba(0,0,0,0.5)', zIndex: 2147483647
        });
        document.body.appendChild(overlay);

        const panel = document.createElement('div');
        Object.assign(panel.style, {
            position: 'fixed', top: '50%', left: '50%',
            transform: 'translate(-50%,-50%)',
            width: '80%', maxHeight: '80%', overflowY: 'auto',
            background: '#fff', padding: '16px',
            borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.3)'
        });
        overlay.appendChild(panel);

        const closeBtn = document.createElement('button');
        closeBtn.textContent = '✕';
        Object.assign(closeBtn.style, {
            position: 'absolute', top: '8px', right: '8px',
            background: 'transparent', border: 'none',
            fontSize: '18px', cursor: 'pointer'
        });
        panel.appendChild(closeBtn);
        closeBtn.addEventListener('click', () => overlay.remove());

        const table = document.createElement('table');
        table.style.width = '100%';
        table.innerHTML = '<tr><th>テキスト</th><th>ページ</th><th>タグ</th><th>操作</th></tr>';
        panel.appendChild(table);

        for (const entry of await getEntries(prefixBM)) {
            const bm = entry.val;
            const tr = document.createElement('tr');
            tr.innerHTML = `
              <td>${bm.text}</td>
              <td><a href="${bm.url}" target="_blank">${bm.title}</a></td>
              <td>${(bm.tags||[]).join(', ')}</td>
              <td>
                <button class="jump">ジャンプ</button>
                <button class="del" style="margin-left:8px">削除</button>
              </td>`;
            table.appendChild(tr);

            tr.querySelector('.jump').addEventListener('click', () => {
                if (bm.url === location.href) window.scrollTo(0, bm.scrollY); else window.open(bm.url, '_blank');
                overlay.remove();
            });
            tr.querySelector('.del').addEventListener('click', async () => {
                if (!confirm('本当に削除しますか?')) return; await GM_deleteValue(entry.key); tr.remove();
            });
        }
    }

    //
    // 10. メニュー生成 & 選択キャッシュ対応
    //
    function createMenu() {
        const container = document.createElement('div');
        Object.assign(container.style, { position: 'fixed', zIndex: 2147483647, cursor: 'move' });
        document.body.appendChild(container);

        let lastSelection = '';
        container.addEventListener('mousedown', () => { lastSelection = window.getSelection().toString(); });
        container.addEventListener('touchstart', () => { lastSelection = window.getSelection().toString(); });

        (async () => {
            const pos = await GM_getValue(posLocalKey);
            if (pos && pos.x != null && pos.y != null) {
                container.style.left = pos.x + 'px'; container.style.top = pos.y + 'px';
            } else {
                container.style.left = '16px'; container.style.bottom = '16px';
            }
        })();
        // 表示ボタン
        const mainBtn = document.createElement('button');
        mainBtn.textContent = '≡';
        Object.assign(mainBtn.style, { width: '48px', height: '48px', borderRadius: '8px', background: '#444', color: '#fff', border: 'none', fontSize: '24px', boxShadow: '0 2px 6px rgba(0,0,0,0.3)' });
        container.appendChild(mainBtn);

        const menu = document.createElement('div');
        Object.assign(menu.style, { display: 'none', flexDirection: 'column', alignItems: 'stretch', gap: '2px', position: 'absolute', background: '#fff', border: '1px solid #ccc', padding: '4px 0', borderRadius: '4px', boxShadow: '0 0 5px rgba(0,0,0,0.2)', maxHeight: 'calc(100vh - 100px)', overflowY: 'auto', width: '180px' });
        container.appendChild(menu);

        function showPalette() {
            const pal = document.createElement('div');
            Object.assign(pal.style, {
                position: 'absolute',
                bottom: '56px',
                left: '0',
                display: 'flex',
                gap: '4px',
                background: '#fff',
                padding: '4px',
                border: '1px solid #ccc',
                borderRadius: '4px',
                boxShadow: '0 0 5px rgba(0,0,0,0.2)'
            });
            const colors = [
              '#FF0000', '#FF3F00', // 赤、赤-橙中間
              '#FF7F00', '#FFBF00', // 橙、橙-黄中間
              '#FFFF00', '#80FF00', // 黄、黄-緑中間
              '#00FF00', '#0080FF', // 緑、緑-青中間
              '#0000FF', '#2600C1', // 青、青-藍中間
              '#4B0082', '#6B00C0', // 藍、藍-紫中間
              '#8B00FF', '#C5007F'  // 紫、紫-赤中間
            ];
            for (const c of colors) {
                const sw = document.createElement('button');
                Object.assign(sw.style, {
                    background: c,
                    width: '24px',
                    height: '24px',
                    border: '1px solid #999',
                    borderRadius: '4px',
                    cursor: 'pointer'
                });
                sw.addEventListener('click', async () => {
                    const sel = window.getSelection().toString().trim() || lastSelection.trim();
                    if (!sel) { alert('テキストを選択してください'); return; }
                    await addEntry(prefixColor, { text: sel, color: c });
                    applyColor(sel, c);
                    pal.remove();
                });
                pal.appendChild(sw);
            }
            container.appendChild(pal);
        }

        const items = [
            { label: 'カラー', fn: () => { menu.style.display = 'none'; showPalette(); } },
            { label: 'ルビ', fn: async () => { menu.style.display = 'none'; const sel = window.getSelection().toString().trim() || lastSelection.trim(); if (!sel) { alert('テキストを選択してください'); return; } const rub = prompt('表示したいルビを入力してください:', ''); if (!rub) return; await addEntry(prefixRuby, { text: sel, ruby: rub }); applyRuby(sel, rub); } },
            { label: 'コンバート', fn: async () => { menu.style.display = 'none'; const sel = window.getSelection().toString().trim() || lastSelection.trim(); if (!sel) { alert('テキストを選択してください'); return; } const rep = prompt('置換後の文字列を入力してください:', ''); if (rep == null) return; await addEntry(prefixConvert, { text: sel, replace: rep }); applyConvert(sel, rep); } },
            { label: 'ノート', fn: async () => { menu.style.display = 'none'; const sel = window.getSelection().toString().trim() || lastSelection.trim(); if (!sel) { alert('テキストを選択してください'); return; } const note = prompt('ノートを入力してください:', ''); if (note == null) return; await addEntry(prefixNote, { text: sel, note }); applyNote(sel, note); } },
            { label: 'ブックマーク追加', fn: async () => { menu.style.display = 'none'; const sel = window.getSelection().toString().trim() || lastSelection.trim(); if (!sel) { alert('テキストを選択してください'); return; } const tags = prompt('タグをカンマ区切りで入力してください:', ''); if (tags == null) return; const bm = { text: sel, title: document.title, url: location.href, scrollY: window.scrollY, tags: tags.split(',').map(s => s.trim()).filter(Boolean) }; await addEntry(prefixBM, bm); applyBookmark(sel, bm); } },
            { label: 'ブックマーク一覧', fn: openBookmarkList },
            { label: '設定管理', fn: openSettingsManager },
            { label: 'エクスポート', fn: exportAll },
            { label: 'インポート', fn: importAll },
            { label: 'リセット', fn: async () => { menu.style.display = 'none'; const sel = window.getSelection().toString().trim() || lastSelection.trim(); if (sel) { if (!confirm(`「${sel}」の設定を削除しますか?`)) return; await deleteEntryByText(prefixColor, sel); await deleteEntryByText(prefixRuby, sel); await deleteEntryByText(prefixConvert, sel); await deleteEntryByText(prefixNote, sel); await deleteEntryByText(prefixBM, sel); location.reload(); } else { if (!confirm('全設定をリセットしますか?')) return; await resetAll(); location.reload(); } } }
        ];

        for (const it of items) {
            const b = document.createElement('button');
            b.textContent = it.label;
            Object.assign(b.style, { display: 'block', width: '100%', padding: '8px 12px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', whiteSpace: 'nowrap' });
            b.addEventListener('mouseover', () => b.style.background = '#eef');
            b.addEventListener('mouseout', () => b.style.background = 'none');
            b.addEventListener('click', it.fn);
            menu.appendChild(b);
        }

        mainBtn.addEventListener('click', () => {
            if (menu.style.display === 'flex') {
                menu.style.display = 'none';
            } else {
                menu.style.left   = '0';
                menu.style.right  = 'auto';
                menu.style.bottom = '48px';
                menu.style.top    = 'auto';
                menu.style.display = 'flex';
            }
        });
    }

    // メニュー初期化
    createMenu();

})();