Hitomi Enhanced

userscript for hitomi.la

// ==UserScript==
// @name         Hitomi Enhanced
// @author       asdf
// @license      Apache-2.0
// @description  userscript for hitomi.la
// @description:en userscript for Hitomi.la
// @match        https://hitomi.la/*
// @exclude      https://hitomi.la/doujinshi/*
// @exclude      https://hitomi.la/manga/*
// @exclude      https://hitomi.la/artistcg/*
// @exclude      https://hitomi.la/gamecg/*
// @exclude      https://hitomi.la/imageset/*
// @exclude      https://hitomi.la/cg/*
// @exclude      https://hitomi.la/reader/*
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @grant        GM_xmlhttpRequest
// @require      https://code.jquery.com/jquery-3.7.1.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js
// @version 0.0.1.20250522021016
// @namespace https://greasyfork.org/users/1284632
// ==/UserScript==

(async function() {
    'use strict';

    let defaultQuery = localStorage.getItem('hitomiDefaultQuery') || '';
    const validClasses = ['dj', 'cg', 'acg', 'manga', 'anime', 'imageset'];

    let default_url = defaultQuery ? `https://hitomi.la/search.html?${encodeURI(defaultQuery)}` : 'https://hitomi.la/';
    if (defaultQuery && window.location.href === 'https://hitomi.la/') {
        window.location.href = default_url;
        return;
    }

    // Initialize IndexedDB
    function initIndexedDB() {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open('HitomiEnhancedDB', 1);

            request.onupgradeneeded = (event) => {
                const db = event.target.result;
                db.createObjectStore('jsonCache');
            };

            request.onsuccess = (event) => {
                resolve(event.target.result);
            };

            request.onerror = (event) => {
                reject(new Error('IndexedDB initialization failed: ' + event.target.error));
            };
        });
    }

    // Store JSON data in IndexedDB
    function storeJsonInIndexedDB(db, jsonData) {
        return new Promise((resolve, reject) => {
            const transaction = db.transaction(['jsonCache'], 'readwrite');
            const store = transaction.objectStore('jsonCache');
            const request = store.put(jsonData, 'test_final_result');

            request.onsuccess = () => resolve(console.log("cached Json"));
            request.onerror = () => reject(new Error('Failed to store JSON in IndexedDB: ' + request.error));
        });
    }

    // Retrieve JSON data from IndexedDB
    function getJsonFromIndexedDB(db) {
        return new Promise((resolve, reject) => {
            const transaction = db.transaction(['jsonCache'], 'readonly');
            const store = transaction.objectStore('jsonCache');
            const request = store.get('test_final_result');

            request.onsuccess = () => resolve(request.result || null);
            request.onerror = () => reject(new Error('Failed to retrieve JSON from IndexedDB: ' + request.error));
        });
    }

    // Fetch JSON data and cache it if not in IndexedDB
    async function getCachedJson() {
        try {
            const db = await initIndexedDB();
            let jsonData = await getJsonFromIndexedDB(db);

            if (!jsonData) {
                jsonData = await new Promise((resolve, reject) => {
                    GM_xmlhttpRequest({
                        method: 'GET',
                        url: 'https://raw.githubusercontent.com/tttt369/hitomi_enhanced/master/urls/result.json',
                        onload: (response) => {
                            try {
                                // レスポンスをJSONとしてパース
                                const data = JSON.parse(response.responseText);
                                resolve(data);
                            } catch (e) {
                                reject(new Error('Failed to parse JSON: ' + e.message));
                            }
                        },
                        onerror: () => {
                            reject(new Error('Failed to fetch JSON'));
                        }
                    });
                });
                await storeJsonInIndexedDB(db, jsonData);
            }

            // jsonDataが文字列の場合、パースを試みる
            if (typeof jsonData === 'string') {
                try {
                    jsonData = JSON.parse(jsonData);
                } catch (e) {
                    console.error('Failed to parse stored JSON:', e);
                    return {};
                }
            }

            // jsonDataが配列でない場合のエラーハンドリング
            if (!Array.isArray(jsonData)) {
                console.error('jsonData is not an array:', jsonData);
                return {};
            }

            const jsonDataMap = {};
            jsonData.forEach((item, index) => {
                jsonDataMap[index] = item;
            });

            return jsonDataMap;
        } catch (error) {
            console.error(error);
            return {};
        }
    }

    // Load JSON data
    const jsonDataMap = await getCachedJson();

    const html = `
        <!DOCTYPE html>
        <html data-bs-theme="dark" lang="ja">
            <head></head>
            <body>
              <nav class="navbar navbar-expand-lg bg-body-tertiary">
                <div class="container-fluid">
                    <a href="/">
                      <img class="navbar-brand" src="//ltn.gold-usergeneratedcontent.net/logo.png" alt="Logo">
                    </a>
                  <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
                    <span class="navbar-toggler-icon"></span>
                  </button>
                  <div class="collapse navbar-collapse w-100" id="navbarNav">
                    <ul class="navbar-nav me-auto">
                      <li class="nav-item"><a class="nav-link" href="/alltags-a.html">tags</a></li>
                      <li class="nav-item"><a class="nav-link" href="/allartists-a.html">artists</a></li>
                      <li class="nav-item"><a class="nav-link" href="/allseries-a.html">series</a></li>
                      <li class="nav-item"><a class="nav-link" href="/allcharacters-a.html">characters</a></li>
                    </ul>
                    <div class="SearchContainer">
                        <form class="d-flex position-relative" role="search">
                            <input id="query-input" class="form-control me-2" type="search" placeholder="Search" aria-label="Search" autocomplete="off">
                            <button class="btn btn-outline-success" type="submit">Search</button>
                        </form>
                        <div class="default-query-container">
                            <div class="default-query-badges"></div>
                            <input id="default-query-input" class="form-control default-query-input" type="text" placeholder="Add to default query">
                            <button id="save-default-btn" class="btn btn-outline-success">Save</button>
                        </div>
                    </div>
                  </div>
                </div>
              </nav>
              <div class="sticky-navbar">
                <button id="tag-picker-btn" class="btn tag-picker-btn">
                </button>
                <div class="btn-group" role="group">
                  <button id="add-tag-btn" class="btn btn-success" disabled>
                    <i class="bi bi-plus-circle-fill"></i>
                  </button>
                  <button id="exclude-tag-btn" class="btn btn-danger" disabled>
                    <i class="bi bi-dash-circle-fill"></i>
                  </button>
                </div>
              </div>
              <div class="container">
                <div class="card-container bg-secondary-subtle">
                  <div class="row g-4 p-3 mt-5 five-columns"> </div>
                </div>
              </div>
              <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
              <script src="//ltn.hitomi.la/searchlib.js"></script>
              <script src="//ltn.hitomi.la/search.js"></script>
            </body>
        </html>
    `;

    const head = `
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css">
        <title>Hitomi Enhanced</title>
        <style>
            .card { display: flex; flex-direction: column; width: 230px; margin: auto; }
            .card-title { font-weight: bold; text-decoration: none; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; word-break: break-all; }
            .card-img-top { object-fit: cover; }
            .card-body { display: flex; flex-direction: column; align-items: center; justify-content: space-between; padding: 5px; }
            .ImageContainer { overflow: hidden; }
            .ImageContainer img { object-fit: cover; width: 100%; height: 250px; }
            .tags-container { scrollbar-width: none; -ms-overflow-style: none; display: flex; overflow-x: auto; white-space: nowrap; background-color: #00000038; margin: 3%; width: 100%; }
            .tags-container a { margin-right: 5%; text-decoration: none; }
            .page-container { text-align: center; padding-bottom: 3%; }
            .page-container li { display: inline-block; padding: 0 2px; }
            .page-container a { text-decoration: none; padding: 4px; color: #444455; }
            .page-container a:hover { text-decoration: none; color: #fff; background-color: #282e3b; }
            .popup { position: relative; }
            .popuptext { visibility: hidden; width: 250px; background-color: #555; color: #fff; border-radius: 6px; position: absolute; z-index: 10; top: -40px; left: 50%; transform: translateX(-50%); }
            .popuptext.show { visibility: visible; }
            h6.badge { margin-top: auto; margin-bottom: 0px; align-self: flex-start; }
            .dropdown-menu { margin-top: 10%; padding: 0px; width: 100%; }
            .header-sort-select { display: flex; justify-content: end; }
            strong { color: cyan; }
            .TableContainer { display: flex; flex-direction: column; align-items: center; overflow-x: auto; }
            .TableContainer table { table-layout: fixed; width: 100%; }
            .TableContainer td a { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-decoration: none; }
            .colon { width: 7%; text-align: center; }
            .type { width: 37%; }
            .col { display: flex; width: 40%; padding-left: 3px; padding-right: 3px; }
            .default-query-container { margin-top: 10px; display: flex; }
            .default-query-badges { display: flex; overflow: auto; margin-bottom: 5px; gap: 5px; scrollbar-width: none; -ms-overflow-style: none; }
            .default-query-input { width: 150px; }
            .save-default-btn { margin-top: 5px; }
            .sticky-navbar { display: flex; position: sticky; top: 0; z-index: 1000; background-color: #343a40; }
            .tag-picker-btn { margin-right: auto; }
            .highlighted-tag { border: 2px solid yellow !important; }
            .sticky-navbar button { font-size: 14px; }
            .numstar-container { display: flex; width: 100%; justify-content: space-between; gap: 10px; align-items: center; margin-top: auto; }
            .star { display: flex; margin: 0; gap: 3px; color: #ffc107; font-size: x-small; align-items: flex-end; }
            .star i { font-size: 14px; }
            .navbar-collapse { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; }
            .navbar-nav.me-auto { flex-grow: 1; margin-right: 10px; }
            .SearchContainer { flex-shrink: 1; min-width: 0; }
            .sticky-navbar--static { display: flex; position: static; top: 0; background-color: #343a40; }
            .sticky-navbar--static button { font-size: 14px; }
            @media (max-width: 991px) {
                .navbar-nav.me-auto { margin-right: 0; flex-basis: 100%; margin-bottom: 10px; }
                .SearchContainer { flex-basis: 100%; max-width: 100%; }
                .default-query-container { flex-direction: column; align-items: stretch; }
                .default-query-input { width: 100%; }
            }
            @media (max-width: 480px) {
                .ImageContainer img { height: 170px; }
                .card { font-size: 70%; }
            }
            @media (min-width: 481px) and (max-width: 992px) {
                .ImageContainer img { height: 220px; }
            }
            @media (min-width: 768px) { .col { width: 30%; } }
            @media (min-width: 992px) and (max-width: 1199px) { .col { width: 25%; } }
            @media (min-width: 992px) { .SearchContainer { max-width: 490px; } }
        </style>
    `;

    const obj_data = {
        div_NextPage: null,
        div_header_sort_select: null
    };

    let jsonData = [];
    let currentBatchIndex = 0;
    const batchSize = 25;

    async function observeGalleryContents(targetDoc, isInitialPage = false) {
        return new Promise((resolve) => {
            const observer = new MutationObserver(() => {
                const div_gallerycontents = targetDoc.querySelectorAll('div.gallery-content div');
                const div_page_containers = $('div.page-container');
                const div_header_sort_select = $('div.header-sort-select');

                if (div_page_containers.length > 0) obj_data.div_NextPage = div_page_containers;
                if (div_header_sort_select.length > 0) obj_data.div_header_sort_select = div_header_sort_select;
                if (div_gallerycontents.length > 2) {
                    observer.disconnect();
                    const filteredContents = Array.from(div_gallerycontents).filter(element =>
                        Array.from(element.classList).some(cls => validClasses.includes(cls))
                    ).map(element => isInitialPage ? $(element).get()[0] : element.cloneNode(true));
                    resolve(filteredContents);
                }
            });
            observer.observe(targetDoc.body, { childList: true, subtree: true });
        });
    }

    async function generateCard(contentUrl, title, imgPicture, Tags, seriesList, language, type, ArtistList, stars = 0, jsonDataMap) {
        const div_col = $('<div class="col"></div>');
        $('.row').append(div_col);
        const div_card = $('<div class="card h-100"></div>');
        div_col.append(div_card);

        const ImageContainer = $('<div class="ImageContainer"></div>');
        div_card.append(ImageContainer);
        const a_ImgUrl = $('<a></a>').attr('href', contentUrl);
        ImageContainer.append(a_ImgUrl);
        const img_top = $(imgPicture).addClass('card-img-top');
        a_ImgUrl.append(img_top);

        const div_card_body = $('<div class="card-body"></div>');
        div_card.append(div_card_body);
        const TableContainer = $('<div class="TableContainer"></div>');
        div_card_body.append(TableContainer);

        const a_card_title = $('<a class="card-title"></a>').attr('href', contentUrl).text(title);
        TableContainer.append(a_card_title);

        const table = $('<table><tbody></tbody></table>');
        TableContainer.append(table);
        const tbody = table.find('tbody');

        const appendListRow = (type, listOrItem, urlPrefix, container, defaultText = 'N/A') => {
            const isList = Array.isArray(listOrItem) || listOrItem instanceof NodeList;
            const list = isList ? Array.from(listOrItem) : [listOrItem];
            const text = list.length && list[0].textContent ? list[0].textContent : defaultText;
            const raw_url = list.length && list[0].href ? list[0].href : "#";
            const aTag = $(`<a>${text}</a>`);

            tbody.append(`<tr><td class="type">${type}</td><td class="colon">:</td><td></td></tr>`);
            tbody.find('tr:last td:last').append(aTag);

            if (list.length && list[0].textContent) {
                aTag.attr('href', defaultQuery === '' ? raw_url : `${default_url + urlPrefix}${encode_search_query_for_url(text)}`);
            }

            if (isList && list.length > 1) {
                const popup = $(`<div class="popup"><i class="bi bi-info-square"></i><span class="popuptext">${list.slice(1).map(s => `${type}: ${s.textContent}`).join('<br>')}</span></div>`);
                container.append(popup);
            }
        };

        appendListRow('language', language, ' language:', div_card_body);
        appendListRow('type', type, ' type:', div_card_body, 'Unknown');
        appendListRow('artist', ArtistList, ' artist:', div_card_body);
        appendListRow('series', seriesList, ' series:', div_card_body);

        const div_numstar_container = $('<div class="numstar-container"></div>');
        div_card_body.append(div_numstar_container);

        const h6_pagenum = $('<h6 class="badge bg-secondary">Loading...</h6>');
        div_numstar_container.append(h6_pagenum);
        const re_num = contentUrl.match(/.*-(\d+)\.html/);
        let galleryId
        if (re_num && re_num[1]) {
            galleryId = re_num[1];
            const item = Object.values(jsonDataMap).find(item => item.id === galleryId);
            if (item) {
                h6_pagenum.text(`${item.pages}p`);
            } else {
                $.getScript(`https://ltn.gold-usergeneratedcontent.net/galleries/${galleryId}.js`, function() {
                    if (typeof galleryinfo !== 'undefined' && galleryinfo.files) {
                        h6_pagenum.text(`${galleryinfo.files.length}p`);
                    }
                }).fail(() => {
                    console.error('Failed to load gallery script:', galleryId);
                    h6_pagenum.text('N/A');
                });
            }
        } else {
            h6_pagenum.text('N/A');
        }

        const h6_star = $('<h6 class="star"></h6>');
        const json_items = Object.values(jsonDataMap).find(item => item.id === galleryId);
        if (json_items) {
            const a_StarHref = $("<a></a>")
            a_StarHref.attr("href", json_items.dmm_url);
            const finalStars = json_items.stars
            const filledStars = Math.floor(finalStars / 10);
            const NumStars = json_items.num_stars
            const hasHalfStar = finalStars % 10 >= 5 ? 1 : 0;

            if (finalStars > 0) {

                for (let i = 0; i < filledStars; i++) {
                    const i_fillstar = $('<i class="bi bi-star-fill"></i>');
                    h6_star.append(i_fillstar);
                }

                if (hasHalfStar) {
                    const i_halfstar = $('<i class="bi bi-star-half"></i>');
                    h6_star.append(i_halfstar);
                }
                h6_star.append(NumStars);
                a_StarHref.append(h6_star)
            }
            div_numstar_container.append(a_StarHref)
        }

        const div_tags_container = $('<div class="tags-container"></div>');
        div_card_body.append(div_tags_container);
        if (Tags.length === 0) {
            div_tags_container.append('<span class="badge bg-primary"></span>');
        } else {
            Tags.forEach(tag => {
                const clone = tag.cloneNode(true);
                if (clone.textContent === '...') return;
                if (clone.href) {
                    const TagUrl = clone.href.match(/\/tag\/(.*)-all.html/);
                    if (TagUrl && TagUrl[1]) {
                        clone.className = 'badge bg-primary';
                        clone.href = defaultQuery === '' ? clone.href : default_url + " " + encode_search_query_for_url(decodeURIComponent(TagUrl[1]));
                        div_tags_container.append(clone);
                    }
                }
            });
        }
    }

    function setupTagScrollEvents() {
        const divDefaultQueryBadges = document.getElementsByClassName('default-query-badges');
        for (const Badge of divDefaultQueryBadges) {
            Badge.addEventListener('wheel', (event) => {
                event.preventDefault();
                Badge.scrollLeft += event.deltaY;
            });
        }
        const divTagsContainers = document.getElementsByClassName('tags-container');
        for (const container of divTagsContainers) {
            container.addEventListener('wheel', (event) => {
                event.preventDefault();
                container.scrollLeft += event.deltaY;
            });
        }
    }

    function setupPopupEvents() {
        document.body.addEventListener('click', (e) => {
            const popup = e.target.closest('.popup');
            if (popup) {
                e.preventDefault();
                const popuptext = popup.querySelector('.popuptext');
                if (popuptext) popuptext.classList.toggle('show');
            }
        });
    }

    async function initializePage(jsonDataMap) {
        let initialContents = await observeGalleryContents(document, true);

        document.documentElement.innerHTML = html;
        document.head.insertAdjacentHTML('beforeend', head);
        document.querySelector('html').setAttribute('data-bs-theme', 'dark');

        const html_container = document.querySelector('.container');
        const html_row = document.querySelector('.row');
        if (obj_data.div_NextPage) html_container.appendChild(obj_data.div_NextPage[1]);
        const newSelect = document.createElement('select');
        newSelect.id = 'custom_sort';

        const option1 = document.createElement('option');
        option1.text = '-';
        option1.value = 'value1';
        newSelect.appendChild(option1);

        const option2 = document.createElement('option');
        option2.text = 'star sort';
        option2.value = 'value2';
        newSelect.appendChild(option2);

        obj_data.div_header_sort_select[0].appendChild(newSelect);
        if (obj_data.div_header_sort_select) html_row.appendChild(obj_data.div_header_sort_select[0]);

        initialContents.forEach(item => {
            const h1Element = item.querySelector('h1.lillie a');
            generateCard(
                h1Element ? h1Element.href : '#',
                h1Element ? h1Element.textContent : 'Unknown',
                item.querySelector('div[class$="-img1"] picture'),
                item.querySelectorAll('td.relatedtags ul li a'),
                item.querySelectorAll('td.series-list ul li a'),
                item.querySelector('table.dj-desc tbody tr:nth-child(3) td a') || { textContent: 'Unknown', href: '#' },
                item.querySelector('table.dj-desc tbody tr:nth-child(2) td a') || { textContent: 'Unknown', href: '#' },
                item.querySelectorAll('div.artist-list ul li a') || { textContent: 'Unknown', href: '#' },
                0,
                jsonDataMap
            );
        });

        setupPopupEvents();
        setupTagScrollEvents();
        setupTagPicker();
    }

    function loadNextPageInIframe(url, jsonDataMap) {
        const iframe = document.createElement('iframe');
        iframe.style.cssText = 'position: fixed; top: 0; left: 0; width: 1px; height: 1px; border: 0; visibility: hidden;';
        iframe.sandbox = 'allow-same-origin allow-scripts';
        iframe.src = url;

        iframe.onload = async () => {
            try {
                const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
                const nextContents = await observeGalleryContents(iframeDoc);
                nextContents.forEach(item => {
                    const h1Element = item.querySelector('h1.lillie a');
                    generateCard(
                        h1Element ? h1Element.href : '#',
                        h1Element ? h1Element.textContent : 'Unknown',
                        item.querySelector('div[class$="-img1"] picture'),
                        item.querySelectorAll('td.relatedtags ul li a'),
                        item.querySelectorAll('td.series-list ul li a'),
                        item.querySelector('table.dj-desc tbody tr:nth-child(3) td a') || { textContent: 'Unknown', href: '#' },
                        item.querySelector('table.dj-desc tbody tr:nth-child(2) td a') || { textContent: 'Unknown', href: '#' },
                        item.querySelectorAll('div.artist-list ul li a') || { textContent: 'Unknown', href: '#' },
                        0,
                        jsonDataMap
                    );
                });
                console.log(`Page ${currentPage} loaded`);
                hasFetched = false;
                setupPopupEvents();
                setupTagScrollEvents();
            } catch (e) {
                console.error('Failed to load next page:', e);
            } finally {
                document.body.removeChild(iframe);
            }
        };

        document.body.appendChild(iframe);
    }

    let currentPage = parseInt(window.location.hash.replace('#', '') || '1', 10);
    let hasFetched = false;

    function getNextPageUrl() {
        const baseUrl = window.location.href.split('#')[0];
        const re_url = /\.html$/;
        currentPage += 1;
        if (window.location.href.match(re_url) || baseUrl === 'https://hitomi.la/') {
            const urlParts = baseUrl.split('page=');
            return `${urlParts[0]}?page=${currentPage}`;
        } else {
            return `${baseUrl}#${currentPage}`;
        }
    }

    async function setupCustomSort(jsonDataMap) {
        const newSelect = document.getElementById('custom_sort');
        newSelect.addEventListener('change', (e) => {
            if (e.target.value === 'value2') {
                currentBatchIndex = 0;
                jsonData = [];
                $('.row').empty();

                jsonData = Object.values(jsonDataMap).sort((a, b) => (b.stars || 0) - (a.stars || 0));
                processBatch(jsonDataMap);
            }
        });
    }

    async function processBatch(jsonDataMap) {
        if (currentBatchIndex >= jsonData.length) {
            console.log('No more data to process');
            return;
        }

        const div_gallery_content = document.createElement('div');
        div_gallery_content.className = 'gallery-content';
        div_gallery_content.style.cssText = 'position: fixed; top: 0; left: 0; width: 1px; height: 1px; border: 0; visibility: hidden;';
        document.body.appendChild(div_gallery_content);

        const dataBatch = jsonData.slice(currentBatchIndex, currentBatchIndex + batchSize);
        currentBatchIndex += batchSize;

        const fetchPromises = dataBatch.map(item => {
            const id = item.id;
            const stars = item.stars || 0;
            const url = `https://ltn.gold-usergeneratedcontent.net/galleryblock/${id}.html`;

            return $.get(url).then(function(html) {
                html = typeof rewrite_tn_paths === 'function' ? rewrite_tn_paths(html) : html;
                const domElements = $.parseHTML(html);
                const container = document.createElement('div');
                container.append(...domElements);
                div_gallery_content.appendChild(container);

                if ('loading' in HTMLImageElement.prototype && typeof flip_lazy_images === 'function') {
                    flip_lazy_images();
                }
                if (typeof moveimages === 'function') {
                    moveimages();
                }
                if (typeof localDates === 'function') {
                    localDates();
                }
                if (typeof limitLists === 'function') {
                    limitLists();
                }

                return { container, stars };
            }).fail(function() {
                console.error(`Failed to fetch HTML from ${url}`);
                return null;
            });
        });

        await Promise.all(fetchPromises).then(results => {
            const validClasses = ['dj', 'cg', 'acg', 'manga', 'anime', 'imageset'];
            results.forEach(result => {
                if (!result) return;
                const { container, stars } = result;
                const galleryItems = Array.from(container.children).filter(element =>
                    Array.from(element.classList).some(cls => validClasses.includes(cls))
                );

                galleryItems.forEach(item => {
                    const h1Element = item.querySelector('h1.lillie a');
                    generateCard(
                        h1Element ? h1Element.href : '#',
                        h1Element ? h1Element.textContent : 'Unknown',
                        item.querySelector('div[class$="-img1"] picture'),
                        item.querySelectorAll('td.relatedtags ul li a'),
                        item.querySelectorAll('td.series-list ul li a'),
                        item.querySelector('table.dj-desc tbody tr:nth-child(3) td a') || { textContent: 'Unknown', href: '#' },
                        item.querySelector('table.dj-desc tbody tr:nth-child(2) td a') || { textContent: 'Unknown', href: '#' },
                        item.querySelectorAll('div.artist-list ul li a') || { textContent: 'Unknown', href: '#' },
                        stars,
                        jsonDataMap
                    );
                });
            });

            document.body.removeChild(div_gallery_content);
            setupPopupEvents();
            setupTagScrollEvents();
            hasFetched = false;
        }).catch(error => {
            console.error('Error processing batch:', error);
            hasFetched = false;
        });
    }

    window.onscroll = function() {
        if (hasFetched) return;
        if ((window.scrollY + window.innerHeight) >= document.documentElement.scrollHeight * 0.9) {
            hasFetched = true;
            if (jsonData.length > 0) {
                processBatch(jsonDataMap);
            } else {
                loadNextPageInIframe(getNextPageUrl(), jsonDataMap);
            }
        }
    };

    const lastUrl = [];
    window.addEventListener('hashchange', () => {
        lastUrl.push(location.hash);
        if (lastUrl.length >= 2 && lastUrl.at(-2) !== lastUrl.at(-1)) {
            location.reload();
        }
    });

    function setupDefaultQueryEditor() {
        const badgesContainer = document.querySelector('.default-query-badges');
        const defaultQueryInput = document.getElementById('default-query-input');
        const saveButton = document.getElementById('save-default-btn');

        function updateBadges() {
            badgesContainer.innerHTML = '';
            const queryParts = defaultQuery.split(' ').filter(part => part.trim());
            queryParts.forEach(part => {
                const badge = document.createElement('span');
                if (part.match(/^-/)) {
                    badge.className = 'badge bg-danger d-flex align-items-center';
                } else {
                    badge.className = 'badge bg-success d-flex align-items-center';
                }
                badge.innerHTML = `${part} <button type="button" class="btn-close btn-close-white ms-1" aria-label="Remove"></button>`;
                badgesContainer.appendChild(badge);

                badge.querySelector('.btn-close').addEventListener('click', () => {
                    defaultQuery = defaultQuery.split(' ').filter(p => p !== part).join(' ');
                    updateBadges();
                    updateUrl();
                    savequery();
                });
            });
        }

        function updateUrl() {
            const newDefaultUrl = `https://hitomi.la/search.html?${encodeURI(defaultQuery)}`;
            document.querySelector('.navbar-brand').href = newDefaultUrl;
            default_url = newDefaultUrl;
        }

        function savequery() {
            localStorage.setItem('hitomiDefaultQuery', defaultQuery);
            console.log('Default query saved:', defaultQuery);
            saveButton.textContent = 'Saved!';
            setTimeout(() => saveButton.textContent = 'Save', 1000);
        }

        function addquery() {
            defaultQuery += ` ${defaultQueryInput.value.trim()}`;
            defaultQueryInput.value = '';
            updateBadges();
            updateUrl();
            savequery();
        }

        defaultQueryInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter' && defaultQueryInput.value.trim()) {
                addquery();
            }
        });

        saveButton.addEventListener('click', () => {
            if (defaultQueryInput.value.trim()) {
                defaultQuery += ` ${defaultQueryInput.value.trim()}`;
                defaultQueryInput.value = '';
                updateBadges();
                updateUrl();
                savequery();
            }
        });

        updateBadges();
    }

    function setupSearch() {
        const input = document.getElementById('query-input');
        const form = document.querySelector('form[role="search"]');
        const stickyNavbar = document.querySelector('.sticky-navbar');

        input.addEventListener("focus", function() {
            stickyNavbar.classList.add('sticky-navbar--static');
            stickyNavbar.classList.remove('sticky-navbar');
        });

        input.addEventListener("blur", function() {
            stickyNavbar.classList.remove('sticky-navbar--static');
            stickyNavbar.classList.add('sticky-navbar');
        });

        const hiddenSuggestions = document.createElement('ul');
        hiddenSuggestions.id = 'search-suggestions';
        hiddenSuggestions.style.display = 'none';
        document.body.appendChild(hiddenSuggestions);

        const suggestionsContainer = document.createElement('div');
        suggestionsContainer.className = 'dropdown-menu';
        form.appendChild(suggestionsContainer);

        const updateSuggestionsVisibility = () => {
            suggestionsContainer.classList.toggle('show', suggestionsContainer.children.length > 0 && input === document.activeElement);
        };

        let committedValue = '';
        let lastInput = '';

        const updateDropdown = () => {
            suggestionsContainer.innerHTML = '';
            const suggestions = hiddenSuggestions.children;
            Array.from(suggestions).forEach(suggestion => {
                const item = document.createElement('a');
                item.className = 'dropdown-item d-flex justify-content-between align-items-center text-wrap';
                item.href = '#';

                const textContainer = document.createElement('span');
                const searchResult = suggestion.querySelector('.search-result')?.innerHTML || '';
                const searchNs = suggestion.querySelector('.search-ns')?.textContent || '';
                textContainer.innerHTML = `${searchResult}${searchNs}`;

                const total = document.createElement('span');
                total.className = 'text-muted ms-2';
                total.textContent = suggestion.querySelector('.search-suggestion_total')?.textContent || '';

                item.appendChild(textContainer);
                item.appendChild(total);

                item.addEventListener('click', (e) => {
                    e.preventDefault();
                    const searchString = suggestion.querySelector('.search-result')?.textContent || '';
                    const re_ns = /\((.*)\)$/;
                    const tagMatch = searchNs.match(re_ns);
                    const tag = tagMatch ? tagMatch[1] : '';
                    const input_value = encode_search_query_for_url(tag + ":" + searchString);

                    if (searchString && tag) {
                        const currentValue = input.value.trim();
                        if (lastInput && currentValue.endsWith(lastInput)) {
                            input.value = currentValue.slice(0, -lastInput.length).trim() + (committedValue ? ' ' : '') + input_value;
                        } else {
                            input.value = committedValue + (committedValue ? ' ' : '') + input_value;
                        }
                        committedValue = input.value;
                        lastInput = '';
                    }
                    updateSuggestionsVisibility();
                });

                suggestionsContainer.appendChild(item);
            });
            updateSuggestionsVisibility();
        };

        input.addEventListener('input', () => {
            const currentValue = input.value.trim();
            if (!currentValue) {
                lastInput = '';
                committedValue = '';
            } else {
                lastInput = currentValue.slice(committedValue.length).trim();
            }
            handle_keyup_in_search_box();
            setTimeout(updateDropdown, 50);
        });

        input.addEventListener('focus', updateSuggestionsVisibility);
        input.addEventListener('blur', () => setTimeout(updateSuggestionsVisibility, 100));

        const handleSearchQuery = () => {
            const userQuery = input.value.trim();
            const combinedQuery = userQuery ? `${defaultQuery} ${userQuery}` : defaultQuery;
            window.location.href = `https://hitomi.la/search.html?${encodeURI(combinedQuery)}`;
        };

        input.addEventListener('keydown', e => {
            if (e.keyCode === 13) {
                e.preventDefault();
                handleSearchQuery();
            }
        });

        form.addEventListener('submit', e => {
            e.preventDefault();
            handleSearchQuery();
        });

        new MutationObserver(updateDropdown).observe(hiddenSuggestions, { childList: true, subtree: true });
    }

    function setupTagPicker() {
        const pickerBtn = document.getElementById('tag-picker-btn');
        const addBtn = document.getElementById('add-tag-btn');
        const excludeBtn = document.getElementById('exclude-tag-btn');
        let isPickerActive = false;
        let selectedTag = null;

        function updatePickerButton(btn, active) {
            btn.innerHTML = 'Select Tag';
            const icon = document.createElement('i');
            icon.className = 'bi bi-eyedropper';
            icon.style.marginLeft = '5px';
            if (!active) {
                btn.innerHTML = '';
            }
            btn.appendChild(icon);
        }

        function extractTagFromHref(href) {
            const match = href.match(/\/tag\/(.*)-all.html/) || href.match(/search\.html\?.*? (.*)$/);
            return match ? encode_search_query_for_url(decodeURIComponent(match[1])) : null;
        }

        function updateDefaultQueryUI() {
            const badgesContainer = document.querySelector('.default-query-badges');
            badgesContainer.innerHTML = '';
            const queryParts = defaultQuery.split(' ').filter(part => part.trim());
            queryParts.forEach(part => {
                const badge = document.createElement('span');
                if (part.match(/^-/)) {
                    badge.className = 'badge bg-danger d-flex align-items-center';
                } else {
                    badge.className = 'badge bg-success d-flex align-items-center';
                }
                badge.innerHTML = `${part} <button type="button" class="btn-close btn-close-white ms-1" aria-label="Remove"></button>`;
                badgesContainer.appendChild(badge);
                badge.querySelector('.btn-close').addEventListener('click', () => {
                    defaultQuery = defaultQuery.split(' ').filter(p => p !== part).join(' ');
                    updateDefaultQueryUI();
                    default_url = `https://hitomi.la/search.html?${encodeURI(defaultQuery)}`;
                    document.querySelector('.navbar-brand').href = default_url;
                    localStorage.setItem('hitomiDefaultQuery', defaultQuery);
                });
            });
            default_url = `https://hitomi.la/search.html?${encodeURI(defaultQuery)}`;
            document.querySelector('.navbar-brand').href = default_url;
        }

        pickerBtn.addEventListener('click', () => {
            isPickerActive = !isPickerActive;
            pickerBtn.classList.toggle('btn-warning', isPickerActive);
            updatePickerButton(pickerBtn, isPickerActive);
            addBtn.disabled = !isPickerActive;
            excludeBtn.disabled = !isPickerActive;

            if (!isPickerActive && selectedTag) {
                selectedTag.classList.remove('highlighted-tag');
                selectedTag = null;
            }
        })

        document.addEventListener('click', (e) => {
            if (!isPickerActive) return;
            const tag = e.target.closest('.tags-container .badge');
            if (tag) {
                e.preventDefault();
                if (selectedTag) selectedTag.classList.remove('highlighted-tag');
                tag.classList.add('highlighted-tag');
                selectedTag = tag;
            }
        });

        addBtn.addEventListener('click', () => {
            if (!selectedTag) return;
            const tagText = extractTagFromHref(selectedTag.href);
            if (tagText && !defaultQuery.includes(tagText)) {
                defaultQuery += defaultQuery ? ` ${tagText}` : tagText;
                localStorage.setItem('hitomiDefaultQuery', defaultQuery);
                updateDefaultQueryUI();
            }
            selectedTag.classList.remove('highlighted-tag');
            selectedTag = null;
        });

        excludeBtn.addEventListener('click', () => {
            if (!selectedTag) return;
            const tagText = extractTagFromHref(selectedTag.href);
            if (tagText) {
                const excludeText = `-${tagText}`;
                if (!defaultQuery.includes(excludeText)) {
                    defaultQuery += defaultQuery ? ` ${excludeText}` : excludeText;
                    localStorage.setItem('hitomiDefaultQuery', defaultQuery);
                    updateDefaultQueryUI();
                }
            }
            selectedTag.classList.remove('highlighted-tag');
            selectedTag = null;
        });

        updatePickerButton(pickerBtn, isPickerActive);
    }

    await initializePage(jsonDataMap);
    setupSearch();
    setupDefaultQueryEditor();
    loadNextPageInIframe(getNextPageUrl(), jsonDataMap);
    setupPopupEvents();
    setupCustomSort(jsonDataMap);
})();