您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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(); })();