您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
ハーメルンのランキングページでタグによるフィルタリングを可能にします
// ==UserScript== // @name ハーメルン - タグフィルター // @namespace http://tampermonkey.net/ // @version 2.9 // @description ハーメルンのランキングページでタグによるフィルタリングを可能にします // @author Damseleng // @match https://syosetu.org/?mode=rank* // @match https://syosetu.org/?mode=search* // @grant none // @license MIT // ==/UserScript== (function() { 'use strict'; const selectedTags = new Set(); let isAndMode = true; let hideViewedNovels = false; const viewedNovels = new Set(); let observer; const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); const isSearchPage = window.location.href.includes('mode=search'); const isSP = document.querySelector('link[href*="sp_v3.css"]') !== null; const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; // カラーパレット const colors = { light: { bg: 'white', text: '#333', border: '#ccc', shadow: 'rgba(0,0,0,0.2)', buttonBg: '#f8f8f8', buttonText: '#333', tagBg: '#f8f8f8', tagText: '#333', secondaryText: '#666', headerBorder: '#eee', alertText: '#ff0000', }, dark: { bg: '#2c2c2c', text: '#f1f1f1', border: '#555', shadow: 'rgba(0,0,0,0.5)', buttonBg: '#444', buttonText: '#f1f1f1', tagBg: '#3a3a3a', tagText: '#f1f1f1', secondaryText: '#aaa', headerBorder: '#444', alertText: '#ff4d4d', } }; const C = isDarkMode ? colors.dark : colors.light; // タグの色情報を保持するMap const tagColorMap = new Map(); // 小説IDを取得する関数 function getNovelId(novelElement) { // 小説コンテナ内の /novel/ を含む最初のリンクを探す const link = novelElement.querySelector('a[href*="/novel/"]'); if (link) { const match = link.href.match(/\/novel\/(\d+)/); if (match) { return match[1]; } } // デバッグ用:IDが見つからなかった要素をログに出力 console.log('Could not find a valid novel ID for element:', novelElement); return null; } // タグを含む要素を取得するセレクタ function getTagElements() { if (isSP) { return Array.from(document.querySelectorAll('.search_box p')).filter(p => p.textContent.trim().startsWith('タグ:') ); } else { return document.querySelectorAll('.section3 .all_keyword'); } } // タグを正規化する関数 function normalizeTag(tag) { return tag.trim() .replace(/[\s ]+/g, '') // 全ての空白文字(全角含む)を除去 .replace(/\[\+\]/g, '') // [+]ボタンのテキストを除去 .normalize('NFKC'); // 互換文字を正規化 } // タグのテキストを抽出する関数 function extractTagText(element) { // タグを含む要素から[+]ボタンを除いたテキストを取得 const tagNodes = Array.from(element.childNodes).filter(node => node.nodeType === Node.TEXT_NODE || // テキストノード (node.nodeType === Node.ELEMENT_NODE && !node.classList.contains('tag-plus-button')) // [+]ボタン以外の要素 ); return tagNodes.map(node => node.textContent).join(''); } // タグを抽出する関数 function extractTags(element, isNovel = false) { const tags = new Set(); if (isSP) { // タグを含む段落のテキストを取得 const alertSpan = element.querySelector('.alert_color'); if (alertSpan) { // alert_colorタグを処理 const alertText = extractTagText(alertSpan); alertText.split(/[\s ]+/).forEach(tag => { const cleanTag = normalizeTag(tag); if (cleanTag) { tags.add(cleanTag); if (!isNovel) tagColorMap.set(cleanTag, C.alertText); } }); } // 通常のタグを処理(alert_color以外) const fullText = extractTagText(element).replace(/^タグ[::]/u, ''); const normalText = alertSpan ? fullText.replace(extractTagText(alertSpan), '') : fullText; normalText.split(/[\s ]+/).forEach(tag => { const cleanTag = normalizeTag(tag); if (cleanTag) tags.add(cleanTag); }); } else { // PC版の処理 element.querySelectorAll('a').forEach(a => { const cleanTag = normalizeTag(a.textContent); if (cleanTag) { tags.add(cleanTag); if (!isNovel && a.classList.contains('alert_color')) { tagColorMap.set(cleanTag, C.alertText); } } }); } return Array.from(tags); } // タグの横に[+]ボタンを追加する関数 function addPlusButtons() { getTagElements().forEach(container => { const tags = extractTags(container); // コンテナの内容をクリア if (isSP) { container.textContent = 'タグ:'; } else { container.innerHTML = ''; } // alert_colorタグと通常タグを分離 const alertTags = tags.filter(tag => tagColorMap.has(tag)); const normalTags = tags.filter(tag => !tagColorMap.has(tag)); if (isSP) { // スマホ版の処理 if (alertTags.length > 0) { const alertSpan = document.createElement('span'); alertSpan.className = 'alert_color'; alertTags.forEach((tag, i) => { if (i > 0) alertSpan.appendChild(document.createTextNode(' ')); const tagSpan = document.createElement('span'); tagSpan.textContent = tag; tagSpan.style.color = C.alertText; alertSpan.appendChild(tagSpan); const plusButton = createPlusButton(tag); alertSpan.appendChild(plusButton); }); container.appendChild(alertSpan); if (normalTags.length > 0) { container.appendChild(document.createTextNode(' ')); } } } else { // PC版の処理 alertTags.forEach((tag, i) => { if (i > 0) container.appendChild(document.createTextNode(' ')); const tagLink = document.createElement('a'); tagLink.href = '#'; tagLink.className = 'alert_color'; tagLink.textContent = tag; container.appendChild(tagLink); const plusButton = createPlusButton(tag); container.appendChild(plusButton); }); if (alertTags.length > 0 && normalTags.length > 0) { container.appendChild(document.createTextNode(' ')); } } // 通常タグを追加 normalTags.forEach((tag, i) => { if (i > 0 || (alertTags.length > 0 && !isSP)) { container.appendChild(document.createTextNode(' ')); } if (!isSP) { const tagLink = document.createElement('a'); tagLink.href = '#'; tagLink.textContent = tag; container.appendChild(tagLink); } else { const tagSpan = document.createElement('span'); tagSpan.textContent = tag; container.appendChild(tagSpan); } const plusButton = createPlusButton(tag); container.appendChild(plusButton); }); }); } // [+]ボタンを作成する関数 function createPlusButton(tag) { const plusButton = document.createElement('span'); plusButton.textContent = '[+]'; plusButton.className = 'tag-plus-button'; plusButton.style.cssText = ` cursor: pointer; color: ${C.secondaryText}; margin: 0 4px; user-select: none; ${isMobile ? 'padding: 5px 8px;' : ''} `; plusButton.title = 'このタグでフィルター'; plusButton.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (!selectedTags.has(tag)) { selectedTags.add(tag); updateSelectedTagsDisplay(); filterNovels(); } }); return plusButton; } // タグセレクターを作成する関数 function createTagSelector() { const container = document.createElement('div'); container.id = 'tag-filter-container'; container.style.cssText = ` position: fixed; background: ${C.bg}; color: ${C.text}; padding: ${isMobile ? '10px' : '15px'}; border: 1px solid ${C.border}; border-radius: 5px; box-shadow: 0 2px 5px ${C.shadow}; z-index: 9999; font-size: ${isMobile ? '14px' : '14px'}; ${isMobile ? ` bottom: 10px; left: 10px; right: 10px; max-height: 30vh; max-width: calc(100vw - 20px); ` : ` top: 10px; right: 10px; min-width: 220px; `} `; // フィルターヘッダー const header = document.createElement('div'); header.style.cssText = ` display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; ${isMobile ? `padding-bottom: 10px; border-bottom: 1px solid ${C.headerBorder};` : ''} `; const title = document.createElement('h3'); title.textContent = 'フィルター中のタグ'; title.style.cssText = ` margin: 0; font-size: ${isMobile ? '16px' : '16px'}; `; header.appendChild(title); // モード切り替えボタン const modeButton = document.createElement('button'); modeButton.id = 'mode-toggle-button'; updateModeButtonText(modeButton); modeButton.style.cssText = ` padding: ${isMobile ? '6px 10px' : '3px 8px'}; border: 1px solid ${C.border}; border-radius: 3px; background: ${C.buttonBg}; color: ${C.buttonText}; cursor: pointer; font-size: ${isMobile ? '14px' : '14px'}; `; modeButton.addEventListener('click', () => { isAndMode = !isAndMode; updateModeButtonText(modeButton); filterNovels(); }); header.appendChild(modeButton); container.appendChild(header); // タグリスト const tagList = document.createElement('div'); tagList.id = 'selected-tags-list'; tagList.style.cssText = ` overflow-y: auto; padding-right: 5px; ${isMobile ? 'max-height: calc(30vh - 120px);' : 'max-height: 60vh;'} `; container.appendChild(tagList); // オプション設定 const optionsContainer = document.createElement('div'); optionsContainer.style.cssText = ` margin-top: 10px; padding-top: 10px; border-top: 1px solid ${C.headerBorder}; `; // 既読非表示チェックボックス const hideViewedContainer = document.createElement('div'); hideViewedContainer.style.cssText = 'margin-bottom: 10px;'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.id = 'hide-viewed-checkbox'; checkbox.checked = hideViewedNovels; checkbox.style.marginRight = '8px'; checkbox.addEventListener('change', (e) => { hideViewedNovels = e.target.checked; localStorage.setItem('hideViewedNovels', hideViewedNovels); filterNovels(); }); const label = document.createElement('label'); label.htmlFor = 'hide-viewed-checkbox'; label.textContent = '既読した小説を隠す'; label.style.cursor = 'pointer'; hideViewedContainer.appendChild(checkbox); hideViewedContainer.appendChild(label); optionsContainer.appendChild(hideViewedContainer); // すべてクリアボタン const clearButton = document.createElement('button'); clearButton.textContent = 'すべてのタグをクリア'; clearButton.style.cssText = ` padding: ${isMobile ? '8px' : '5px 10px'}; border: 1px solid ${C.border}; border-radius: 3px; background: ${C.buttonBg}; color: ${C.buttonText}; cursor: pointer; width: 100%; font-size: ${isMobile ? '14px' : '14px'}; margin-bottom: 5px; `; clearButton.addEventListener('click', () => { selectedTags.clear(); updateSelectedTagsDisplay(); filterNovels(); }); optionsContainer.appendChild(clearButton); // 既読履歴リセットボタン const resetViewedButton = document.createElement('button'); resetViewedButton.textContent = '既読履歴をリセット'; resetViewedButton.style.cssText = clearButton.style.cssText; resetViewedButton.addEventListener('click', () => { if (confirm('本当に既読履歴をリセットしますか?')) { viewedNovels.clear(); localStorage.removeItem('viewedNovelIds'); filterNovels(); } }); optionsContainer.appendChild(resetViewedButton); container.appendChild(optionsContainer); return container; } // モードボタンのテキストを更新 function updateModeButtonText(button) { button.textContent = isAndMode ? 'AND' : 'OR'; button.title = isAndMode ? 'すべてのタグを含む小説を表示(タップでORモードに切替)' : 'いずれかのタグを含む小説を表示(タップでANDモードに切替)'; } // 選択タグの表示を更新 function updateSelectedTagsDisplay() { const tagList = document.getElementById('selected-tags-list'); if (!tagList) return; tagList.innerHTML = ''; if (selectedTags.size === 0) { const message = document.createElement('div'); message.style.cssText = ` color: ${C.secondaryText}; padding: ${isMobile ? '8px 0' : '5px 0'}; `; message.textContent = 'タグが選択されていません'; tagList.appendChild(message); return; } Array.from(selectedTags).sort().forEach(tag => { const tagDiv = document.createElement('div'); tagDiv.style.cssText = ` margin: 4px 0; padding: ${isMobile ? '8px' : '5px'}; background: ${C.tagBg}; border-radius: 3px; display: flex; justify-content: space-between; align-items: center; `; const tagText = document.createElement('span'); tagText.textContent = tag; if (tagColorMap.has(tag)) { tagText.style.color = tagColorMap.get(tag); } tagText.style.marginRight = '10px'; const removeButton = document.createElement('span'); removeButton.textContent = '×'; removeButton.style.cssText = ` cursor: pointer; color: ${C.secondaryText}; padding: ${isMobile ? '5px 10px' : '0 5px'}; font-size: ${isMobile ? '18px' : '14px'}; `; removeButton.addEventListener('click', () => { selectedTags.delete(tag); updateSelectedTagsDisplay(); filterNovels(); }); tagDiv.appendChild(tagText); tagDiv.appendChild(removeButton); tagList.appendChild(tagDiv); }); } function filterNovels() { console.log(`Filtering novels. Hide viewed: ${hideViewedNovels}. Total viewed: ${viewedNovels.size}`); const selector = isSP ? '.search_box' : '.section3'; document.querySelectorAll(selector).forEach(novel => { let shouldShow = true; const novelId = getNovelId(novel); // 既読フィルター if (hideViewedNovels && novelId && viewedNovels.has(novelId)) { console.log(`Hiding viewed novel: ${novelId}`); shouldShow = false; } // タグフィルター if (shouldShow && selectedTags.size > 0) { const tagContainer = isSP ? Array.from(novel.querySelectorAll('p')).find(p => p.textContent.trim().startsWith('タグ:') ) : novel.querySelector('.all_keyword'); if (!tagContainer) { shouldShow = false; } else { const novelTags = extractTags(tagContainer, true).map(tag => normalizeTag(tag)); const normalizedSelectedTags = Array.from(selectedTags).map(tag => normalizeTag(tag)); if (isAndMode) { shouldShow = normalizedSelectedTags.every(selectedTag => novelTags.some(novelTag => novelTag === selectedTag) ); } else { shouldShow = normalizedSelectedTags.some(selectedTag => novelTags.some(novelTag => novelTag === selectedTag) ); } } } novel.style.display = shouldShow ? '' : 'none'; }); } // 既読小説を監視するObserverを初期化 function setupIntersectionObserver() { const selector = isSP ? '.search_box' : '.section3'; const novels = document.querySelectorAll(selector); // スマホでフィルターパネルに隠れる問題を考慮して、より厳しい条件にする const options = { threshold: isMobile ? 0.7 : 0.3, // スマホでは70%、PCでは30%が見える必要がある rootMargin: isMobile ? '0px 0px -200px 0px' : '0px' // スマホでは下部200px分を除外 }; observer = new IntersectionObserver((entries) => { entries.forEach(entry => { // isIntersectingに関わらず、Observerの動作をすべてログに出力する console.log('IntersectionObserver entry:', entry); if (entry.isIntersecting) { const novelId = getNovelId(entry.target); if (novelId && !viewedNovels.has(novelId)) { console.log(`Marking novel as viewed: ${novelId}`); viewedNovels.add(novelId); localStorage.setItem('viewedNovelIds', JSON.stringify(Array.from(viewedNovels))); } } }); }, options); novels.forEach(novel => observer.observe(novel)); } // データをロードする function loadData() { const storedIds = localStorage.getItem('viewedNovelIds'); if (storedIds) { JSON.parse(storedIds).forEach(id => viewedNovels.add(id)); } hideViewedNovels = localStorage.getItem('hideViewedNovels') === 'true'; } // メイン処理 function init() { console.log(`Script start. isSP: ${isSP}, isMobile: ${isMobile}`); loadData(); const waitForContent = () => { const contentSelector = isSP ? '.search_box' : '.section3'; if (!document.querySelector(contentSelector)) { setTimeout(waitForContent, 500); return; } // スマホ表示の場合、フィルターパネルにコンテンツが隠れないように下部にダミーのスペーサー要素を追加 if (isSP && !document.getElementById('userscript-bottom-spacer')) { const spacer = document.createElement('div'); spacer.id = 'userscript-bottom-spacer'; // フィルターパネルの高さを考慮してスペーサーを大きくする spacer.style.height = '50vh'; spacer.style.minHeight = '300px'; // 最小高さを保証 // 考えられるメインコンテナにスペーサーを追加する const mainContainer = document.getElementById('main') || document.getElementById('container') || document.body; mainContainer.appendChild(spacer); console.log('Spacer added to:', mainContainer.tagName, mainContainer.id ? `#${mainContainer.id}`: ''); } const tagSelector = createTagSelector(); document.body.appendChild(tagSelector); addPlusButtons(); updateSelectedTagsDisplay(); setupIntersectionObserver(); filterNovels(); // 初期フィルタリング }; waitForContent(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();