ハーメルン - タグフィルター

ハーメルンのランキングページでタグによるフィルタリングを可能にします

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