班固米右上角快速搜索

右上角搜索框输入文字后快速显示部分搜索结果

// ==UserScript==
// @name         班固米右上角快速搜索
// @namespace    https://bgm.tv/group/topic/409735
// @version      0.1.4
// @description  右上角搜索框输入文字后快速显示部分搜索结果
// @author       mov
// @include      /^https?://(bangumi\.tv|bgm\.tv|chii\.in)/.*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    let timeoutId;
    const debounceDelay = 800; // 防抖延迟时间
    let currentRequestId = 0; // 当前请求ID
    let selectedIndex = -1; // 当前选中的结果索引
    let isComposing = false; // 是否正在输入法输入中

    const searchInput = document.querySelector('#search_text');
    const headerSearch = document.querySelector('#headerSearch');
    const siteSearchSelect = document.querySelector('#siteSearchSelect');

    if (!searchInput || !headerSearch || !siteSearchSelect) return;

    const searchClass = headerSearch.querySelector('form').action.split('/').pop();

    const styleSheet = document.createElement("style");
    styleSheet.innerText = /* css */`
        #searchOverlay {
            position: absolute;
            top: calc(100% + 5px);
            left: 0;
            width: 100%;
            background-color: rgba(255, 255, 255, 0.7);
            z-index: 91;
            display: none;
            border-radius: 15px;
        }

        html[data-theme="dark"] #searchOverlay {
            background: rgba(80, 80, 80, 0.7);
        }

        #suggestionBox {
            position: absolute;
            top: calc(100% + 5px);
            left: 0;
            width: 100%;
            max-height: 300px;
            overflow: hidden;
            background-color: rgba(254, 254, 254, 0.9);
            box-shadow: inset 0 1px 1px hsla(0, 0%, 100%, 0.3), inset 0 -1px 0 hsla(0, 0%, 100%, 0.1), 0 2px 4px hsla(0, 0%, 0%, 0.2);
            backdrop-filter: blur(5px);
            border-radius: 15px;
            z-index: 90;
            display: none;
        }
        #suggestionBox ul.ajaxSubjectList {
            overflow-y: auto;
            max-height: 300px;
            scrollbar-width: thin;
        }

        html[data-theme="dark"] #suggestionBox {
            background: rgba(80, 80, 80, 0.7);
        }

        #suggestionBox ul.ajaxSubjectList li:hover,
        #suggestionBox ul.ajaxSubjectList li.selected {
            background: #4F93CF;
            background: -moz-linear-gradient(top, #6BA6D8, #4F93CF);
            background: -o-linear-gradient(top, #6BA6D8, #4F93CF);
            background: -webkit-gradient(linear, left top, left bottom, from(#6BA6D8), to(#4F93CF));
        }

        #suggestionBox ul.ajaxSubjectList li.selected a {
            color: #FFF;
        }

        #suggestionBox ul.ajaxSubjectList li.selected small {
            color: #eee;
        }

        #errorMessage {
            padding: 8px;
        }

        #headerNeue2 #headerSearch #suggestionBox .inner {
            display: block;
            width: auto;
        }
    `;
    document.head.appendChild(styleSheet);

    searchInput.autocomplete = 'off';

    const suggestionBox = document.createElement('div');
    suggestionBox.id = 'suggestionBox';
    headerSearch.appendChild(suggestionBox);

    const overlay = document.createElement('div');
    overlay.id = 'searchOverlay';
    headerSearch.appendChild(overlay);

    searchInput.addEventListener('compositionstart', function() {
        isComposing = true;
    });

    searchInput.addEventListener('compositionend', function() {
        isComposing = false;
        handleInput(); // 输入法结束后立即处理输入
    });

    searchInput.addEventListener('input', function() {
        if (!isComposing) handleInput();
    });

    function handleInput() {
        clearTimeout(timeoutId);

        timeoutId = setTimeout(function() {
            const keyword = searchInput.value.trim();
            if (keyword) {
                fetchSearchSuggestions(keyword, ++currentRequestId); // 发出新请求时增加请求ID
            } else {
                suggestionBox.style.display = 'none';
                overlay.style.display = 'none';
            }
        }, debounceDelay);
    }

    siteSearchSelect.addEventListener('change', function() {
        const query = searchInput.value.trim();
        if (query) {
            fetchSearchSuggestions(query, ++currentRequestId);
        }
    });

    document.addEventListener('click', function(event) {
        if (!headerSearch.contains(event.target)) {
            suggestionBox.style.display = 'none';
            overlay.style.display = 'none';
        }
    });

    searchInput.addEventListener('focus', function() {
        if (suggestionBox.innerHTML.trim() !== '') {
            suggestionBox.style.display = 'block';
        }
    });

    searchInput.addEventListener('keydown', function(event) {
        const suggestionItems = suggestionBox.querySelectorAll('li');
        if (event.key === 'Escape') {
            suggestionBox.style.display = 'none';
            overlay.style.display = 'none';
        } else if (event.key === 'ArrowDown') {
            event.preventDefault();
            if (selectedIndex < suggestionItems.length - 1) {
                selectedIndex++;
                updateSelection(suggestionItems);
            }
        } else if (event.key === 'ArrowUp') {
            event.preventDefault();
            if (selectedIndex > 0) {
                selectedIndex--;
                updateSelection(suggestionItems);
            } else {
                selectedIndex = -1;
                suggestionItems[0].classList.remove('selected');
            }
        } else if (event.key === 'Enter') {
            if (selectedIndex >= 0 && selectedIndex < suggestionItems.length) {
                event.preventDefault();
                suggestionItems[selectedIndex].querySelector('a').click();
            }
        }
    });

    function updateSelection(items) {
        items.forEach((item, index) => {
            if (index === selectedIndex) {
                item.classList.add('selected');
                item.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
            } else {
                item.classList.remove('selected');
            }
        });
    }

    const createFetch = method => async (url, body) => {
        const options = method === 'POST' ? { method, body: JSON.stringify(body) } : { method };
        try {
            const response = await fetch(url, options);
            if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
            return await response.json();
        } catch (e) {
            console.error(e);
            return null;
        }
    };

    const fetchGet = createFetch('GET');
    const fetchPost = createFetch('POST');

    const postSearch = async (cat, keyword, filter) => {
        const url = `https://api.bgm.tv/v0/search/${cat}`;
        const body = { keyword, filter };
        const result = await fetchPost(url, body);
        return result?.data;
    };

    const searchSubject = async (keyword, type='') => { // 旧API结果为空时发生CORS错误,但新API搜索结果不准确,仍用旧API
        const url = `https://api.bgm.tv/search/subject/${encodeURIComponent(keyword)}?type=${type}`;
        const result = await fetchGet(url);
        return result?.list;
    };
    // const searchSubject = (keyword, type) => postSearch('subjects', keyword, { type: [+type].filter(a => a) });
    const searchPrsn = keyword => postSearch('persons', keyword);
    const searchCrt = keyword => postSearch('characters', keyword);
    const searchPrsnCrt = async (keyword) => {
        const [prsn, crt] = await Promise.all([searchPrsn(keyword), searchCrt(keyword)]);
        return !prsn ? crt : !crt ? prsn : [...prsn, ...crt];
    };

    async function fetchSearchSuggestions(keyword, requestId) {
        if (requestId !== currentRequestId) return; // 如果请求ID不匹配,直接返回

        overlay.style.height = getComputedStyle(suggestionBox).getPropertyValue('height');
        overlay.style.display = 'block';

        const type = siteSearchSelect.value;
        const data = searchClass === 'subject_search'
                   ? type === 'person' ? await searchPrsnCrt(keyword)
                                       : await searchSubject(keyword, type)
                   : type === 'all' ? await searchPrsnCrt(keyword)
                                    : type === 'prsn' ? await searchPrsn(keyword)
                                                      : await searchCrt(keyword);

        if (requestId === currentRequestId) { // 如果请求ID不匹配,直接返回
            if (data) {
                const cat = searchClass === 'subject_search'
                          ? type === 'person' ? 'person' : 'subject'
                          : type === 'prsn' ? 'person' : 'character';
                renderList(data, cat);
            } else {
                suggestionBox.innerHTML = '<div id="errorMessage">搜索失败</div>';
                suggestionBox.style.display = 'block';
            }
        }
        overlay.style.display = 'none';
    }

    function renderList(data, cat) {
        selectedIndex = -1; // 重置选中索引

        if (data.length === 0) {
            suggestionBox.style.display = 'none';
            overlay.style.display = 'none';
            return;
        }

        const html = `<ul id="subjectList" class="subjectList ajaxSubjectList">
        ${ data.reduce((m, { id, type, images, name,
                             name_cn, career, infobox }) => {
            name_cn ??= infobox?.find(({ key }) => key === '简体中文名')?.value;
            if (cat !== 'subject') cat = career ? 'person' : 'character';
            type = cat === 'subject' ? ['书籍', '动画', '音乐', '游戏', '', '三次元'][type - 1]
                                     : career ? '现实人物' : '虚拟角色';
            const grid = cat === 'subject' ? images?.grid : images?.grid.replace('/g/', '/s/');
            const exist = v => v ? v : '';
            m += `<li class="clearit">
                    <a href="/${ cat }/${ id }" class="avatar h">
                      ${ grid ? `<img src="${ grid }" class="avatar ll">` : ''}
                    </a>
                    <div class="inner">
                      <small class="grey rr">${ exist(type) }</small>
                      <p><a href="/${ cat }/${ id }" class="avatar h">${ name }</a></p>
                      <small class="tip">${ exist(name_cn) }</small>
                    </div>
                  </li>`;
            return m;
        }, '') }
        </ul>`;
        suggestionBox.innerHTML = html;
        suggestionBox.style.display = 'block';
        suggestionBox.scrollTop = 0;
    }

})();
长期地址
遇到问题?请前往 GitHub 提 Issues。