ExHentai Viewer

manage your favorite tags, enhance searching, improve comic page

ของเมื่อวันที่ 21-04-2023 ดู เวอร์ชันล่าสุด

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey to install this script.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name ExHentai Viewer
// @namespace Violentmonkey Scripts
// @description manage your favorite tags, enhance searching, improve comic page
// @description:zh-CN 管理标签,增强搜索,改进漫画页面
// @match *://exhentai.org/*
// @match *://e-hentai.org/*
// @grant       GM_setValue
// @grant       GM_getValue
// @version 0.63
// ==/UserScript==
/*
 * To-do:
 *   1. preload images?
 */

const currentVersion = '0.62';
const sortFilters = false;
const $ = selector => document.querySelector(selector);
const $$ = selector => document.querySelectorAll(selector);
const create = tag => document.createElement(tag);
let data = {
    custom_filter: GM_getValue('custom_filter', {}),
    script_config: GM_getValue('script_config', {})
};

init();

function init() {
    // upgrade and load script config
    loadScriptData();
    // router
    const url = window.location.href;
    if (url.includes('/s/')) {
        // comic page
        EHViewer('s');
    } else if (url.includes('/mpv')) {
        // multi page mode
        EHViewer('mpv');
    } else if (url.includes('/g/')) {
        // gallery page
        handleGallery();
    } else if ($('#searchbox')) {
        // add panel below the searchbox
        handleSearchBox();
    }
}

function loadScriptData() {
    switch (data.script_config.current_version) {
        // don't break, just go through the flow to upgrade script data by version
        case undefined:
            // just install
        default:
            data.script_config.current_version = currentVersion;
            GM_setValue('custom_filter', data.custom_filter);
            GM_setValue('script_config', data.script_config);
    }
}

function changeStyle(css, mode, id = 'ehv-style') {
    let cssEl = $('#' + id);
    if (!cssEl) {
        cssEl = create('style');
        cssEl.type = 'text/css';
        cssEl.id = id;
        document.head.append(cssEl);
    }
    switch (mode) {
        case 'add':
            cssEl.innerHTML += css;
            break;
        case 'replace':
            cssEl.innerHTML = css;
            break;
    }
}

function addBtnContainer() {
    let btnStyle = `
          #ehv-btn-c{
              text-align: center;
              list-style: none;
              position: fixed;
              bottom: 30px;
              right: 30px;
              z-index: 999;
          }
          .ehv-btn {
              line-height: 32px;
              font-size: 16px;
              padding: 2px;
              margin: 5px;
              color: #233;
              position: relative;
              width: 32px;
              height: 32px;
              border: none;
              border-radius: 50%;
              box-shadow: 0 0 3px 0 #0006;
              cursor: pointer;
              user-select: none;
              outline: none;
              background-color: #fff;
              background-repeat: no-repeat;
              background-position: center;
          }
          .ehv-btn:hover{
              box-shadow: 0 0 3px 1px #0005!important;
          }
          .ehv-btn:active {
              top: 1px;
              box-shadow: 0 0 1px 1px #0004!important;
          }`;
    let btnContainer = create('ul');
    btnContainer.id = 'ehv-btn-c';
    document.body.append(btnContainer);
    changeStyle(btnStyle, 'add', 'ehv-btn-c-style');
    return btnContainer;
}

function addBtn(btn, container) {
    let btnEl = create('li');
    if (btn.icon.startsWith('data:image')) {
        btnEl.style.backgroundImage = "url('" + btn.icon + "')";
    } else {
        btnEl.innerHTML = btn.icon;
    }
    btnEl.classList.add('ehv-btn');
    btnEl.addEventListener(btn.event, btn.cb);
    container.append(btnEl);
    return btnEl;
}

function EHViewer(mode) {
    const host = window.location.host;
    const svgIcons = {
        zoomIn: 'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="rgb(120,120,120)" d="M14 7H9V2a1 1 0 0 0-2 0v5H2a1 1 0 0 0 0 2h5v5a1 1 0 0 0 2 0V9h5a1 1 0 0 0 0-2z"></path></svg>',
        zoomOut:
            'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect fill="rgb(120,120,120)" x="2" y="7" width="12" height="2" rx="1"></rect></svg>',
        prevPage:
            'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="rgb(120,120,120)" d="M15 7H3.414l4.293-4.293a1 1 0 0 0-1.414-1.414l-6 6a1 1 0 0 0 0 1.414l6 6a1 1 0 0 0 1.414-1.414L3.414 9H15a1 1 0 0 0 0-2z"></path></svg>',
        nextPage:
            'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="rgb(120,120,120)" d="M15.707 7.293l-6-6a1 1 0 0 0-1.414 1.414L12.586 7H1a1 1 0 0 0 0 2h11.586l-4.293 4.293a1 1 0 1 0 1.414 1.414l6-6a1 1 0 0 0 0-1.414z"></path></svg>',
        reload: 'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="rgb(120,120,120)" d="M15 1a1 1 0 0 0-1 1v2.418A6.995 6.995 0 1 0 8 15a6.954 6.954 0 0 0 4.95-2.05 1 1 0 0 0-1.414-1.414A5.019 5.019 0 1 1 12.549 6H10a1 1 0 0 0 0 2h5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"></path></svg>',
        gallery:
            'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g fill="rgb(120,120,120)"><rect x="1" y="1" width="6" height="6" rx="1" ry="1"></rect><rect x="9" y="1" width="6" height="6" rx="1" ry="1"></rect><rect x="1" y="9" width="6" height="6" rx="1" ry="1"></rect><rect x="9" y="9" width="6" height="6" rx="1" ry="1"></rect></g></svg>'
    };
    let css = '';
    let btnContainer = addBtnContainer();
    let currentScale;

    switch (mode) {
        case 's':
            currentScale = 0.45;
            css += `
                  #i1{
                      width:98%!important;
                      max-width:98%!important;
                      min-width:800px;
                      background-color: inherit!important;
                      border: none!important
                  }
                  #img{
                      max-width:none!important;
                      max-height:none!important;
                      background-color: #4f535b!important;
                      padding: 8px;
                      border: 1px solid #000;
                      border-radius: 2px;
                  }
                  h1, #i2, #i5, #i6, #i7, .ip, .sn{
                      display:none!important;
                  }
                  ::-webkit-scrollbar{
                      display:none;
                  }
                  html{
                      scrollbar-width: none;
                  }`;
            // add btns
            [
                { icon: svgIcons.zoomIn, event: 'mousedown', cb: e => zoomer(zoomInS) },
                { icon: svgIcons.zoomOut, event: 'mousedown', cb: e => zoomer(zoomOutS) },
                { icon: svgIcons.prevPage, event: 'click', cb: prevPage },
                { icon: svgIcons.nextPage, event: 'click', cb: nextPage },
                { icon: svgIcons.reload, event: 'click', cb: e => $('#loadfail').click() },
                { icon: svgIcons.gallery, event: 'click', cb: e => $('div.sb>a').click() }
            ].forEach(v => addBtn(v, btnContainer));
            // handle key events
            document.body.addEventListener('keydown', e => {
                switch (e.key) {
                    case '=':
                        zoomInS(0.1);
                        break;
                    case '-':
                        zoomOutS(0.1);
                        break;
                    case ',':
                        window.scroll(0, 0);
                        break;
                    case '.':
                        window.scroll(0, window.innerHeight);
                        break;
                    case '[':
                        window.scrollBy(0, -window.innerHeight * 0.3);
                        break;
                    case ']':
                        window.scrollBy(0, window.innerHeight * 0.3);
                        break;
                }
            });
            // set scale on page load
            setNewPage();
            // set scale on img load
            let observer = new MutationObserver(setNewPage);
            observer.observe($('#i3'), { attributes: false, childList: true, subtree: false });
            break;
        case 'mpv':
            currentScale = 1;
            css += '#pane_images_inner>div{margin:auto;}';
            // add btns
            [
                { icon: svgIcons.zoomIn, event: 'mousedown', cb: e => zoomer(zoomInMpv) },
                { icon: svgIcons.zoomOut, event: 'mousedown', cb: e => zoomer(zoomOutMpv) }
            ].forEach(v => addBtn(v, btnContainer));
            document.body.addEventListener('keydown', e => {
                switch (e.key) {
                    case '=':
                        zoomInMpv(0.1);
                        break;
                    case '-':
                        zoomOutMpv(0.1);
                        break;
                }
            });
            break;
    }

    // add style
    if (data.script_config.hide_button) {
        css += `
              #ehv-btn-c{
                  padding: 80px 30px 30px 80px;
                  bottom: 0!important;
                  right: -80px!important;
                  transition-duration: 300ms;
              }
              #ehv-btn-c:hover{
                  padding: 80px 30px 30px 20px!important;
                  right: 0!important;
              }`;
    }
    css += '.ehv-btn{background-color: #44454B!important;}';
    if (host === 'e-hentai.org') {
        // btn color
        css = css.replaceAll('#44454B', '#D3D0D1');
        // page bg & border (for mode 's')
        css = css.replace('#4f535b', '#EDEBDF').replace('1px solid #000', '1px solid #5C0D12');
    }
    changeStyle(css, 'replace', 'ehv-style');

    // === functions ===
    function zoomer(cb) {
        // long press to trigger continuous scaling(zoomTimeout > zoomInterval)
        let zoomInterval = -1;
        let zoomTimeout = setTimeout(function () {
            zoomInterval = setInterval(cb, 100);
        }, 800);
        document.addEventListener(
            'mouseup',
            e => {
                if (zoomInterval === -1) {
                    cb(0.1);
                    clearTimeout(zoomTimeout);
                } else {
                    clearInterval(zoomInterval);
                }
            },
            { once: true }
        );
    }
    function prevPage() {
        const currentPage = $('.sn>span:first-child').innerText;
        currentPage !== '1' ? $('#prev').click() : alert('This is first page (⊙_⊙)');
    }
    function nextPage() {
        const currentPage = $('.sn span:first-child').innerText;
        const lastPage = $('.sn span:last-child').innerText;
        currentPage !== lastPage ? $('#next').click() : alert('This is last page (⊙ω⊙)');
    }
    function setNewPage() {
        // add page indicator
        let footer = $('#i4 > div:first-child');
        const currentPage = $('.sn span:first-child').innerText;
        const totalPage = $('.sn span:last-child').innerText;
        footer.innerHTML = currentPage + 'P / ' + totalPage + 'P :: ' + footer.innerText + ' :: ';
        // add origin source
        let imgLink = $('#i7>a');
        imgLink ? footer.append(imgLink) : (footer.innerHTML += 'No download');
        // inherit the zoom scale of previous page
        zoom4S();
    }
    function zoomInS(pace = 0.02) {
        const targetScale = currentScale * (1 + pace);
        currentScale = targetScale > 1 ? 1 : targetScale;
        zoom4S();
    }
    function zoomOutS(pace = 0.02) {
        const img = $('#img');
        const targetScale = currentScale * (1 - pace);
        currentScale = img.height > window.innerHeight ? targetScale : currentScale;
        zoom4S();
    }
    function zoom4S() {
        const img = $('#img');
        img.style.width = currentScale * 100 + '%';
        img.style.height = 'auto';
    }
    function zoomInMpv(pace = 0.02) {
        const maxWidth = Number($('#pane_images').style.width.replace('px', '')) - 20;
        const originalWidth = Number($('#image_1').style.maxWidth.replace('px', ''));
        currentScale = originalWidth * (1 + pace) < maxWidth ? currentScale + pace : currentScale;
        zoom4Mpv(originalWidth * currentScale);
    }
    function zoomOutMpv(pace = 0.02) {
        const minWidth = 200;
        const originalWidth = Number($('#image_1').style.maxWidth.replace('px', ''));
        currentScale = originalWidth * (currentScale - pace) > minWidth ? currentScale - pace : currentScale;
        zoom4Mpv(originalWidth * currentScale);
    }
    function zoom4Mpv(width) {
        let mpvStyle =
            'img[id^="imgsrc"], div[id^="image"]{width:mpvWidth!important;height:auto!important; max-width:100%!important;min-width:200px!important}"';
        mpvStyle = mpvStyle.replace('mpvWidth', width + 'px');
        changeStyle(mpvStyle, 'replace', 'custom-width');
    }
}

function handleGallery() {
    // add searchbox
    let searchBox = create('form');
    searchBox.innerHTML = `
          <p class="nopm">
              <input type="text" id="f_search" name="f_search" placeholder="Search Keywords" value="" size="60">
              <input type="submit" name="f_apply" value="Search">
          </p>`;
    searchBox.setAttribute(
        'style',
        'display: none; width: 720px; margin: 10px auto; border: 2px ridge black; padding: 10px;'
    );
    searchBox.setAttribute('action', 'https://' + window.location.host + '/');
    searchBox.setAttribute('method', 'get');
    $('.gm').before(searchBox);

    // add btn to show/hide searchbox
    let tbody = $('#taglist > table > tbody');
    tbody.innerHTML += `
          <tr>
              <td class="tc">EHV:</td>
              <td>
                  <div id="ehv-panel-btn" class="gt" style="cursor:pointer">show panel</div>
              </td>
          </tr>`;
    $('#ehv-panel-btn').addEventListener('click', e => {
        const t = e.target;
        if (t.innerText == 'show panel') {
            searchBox.style.display = 'block';
            t.innerText = 'hide panel';
        } else {
            searchBox.style.display = 'none';
            t.innerText = 'show panel';
        }
    });

    // add panel
    setPanel();

    // add gallery tag to searchbox by right-click
    tbody.addEventListener('contextmenu', applyGalleryTag);

    function applyGalleryTag(e) {
        const t = e.target;
        if (t.tagName === 'A') {
            e.preventDefault();
            const searchInput = $('#f_search');
            let tag = t.href.split('/').pop().replaceAll('+', ' ');
            filter = `"${tag}" `;
            // add tailing space
            if(searchInput.value.length > 0 && searchInput.value.endsWith(' ') === false) searchInput.value += ' ';
            searchInput.value.includes(filter)
                ? (searchInput.value = searchInput.value.replace(filter, ''))
                : (searchInput.value += filter);
        }
    }
}

function handleSearchBox() {
    setPanel();
    const ehvPanel = $('#ehv-panel');
    if (data.script_config.hide_panel) {
        ehvPanel.style.display = 'none';
        const a = create('a');
        a.id = 'ehv-panel-btn';
        a.innerText = '[Show EHV Panel]';
        a.setAttribute('href', '#');
        a.setAttribute('style', 'margin-left: 1em;');
        a.addEventListener('click', function () {
            if (ehvPanel.style.display == 'none') {
                ehvPanel.style.display = 'block';
                a.innerText = '[Hide EHV Panel]';
            } else {
                ehvPanel.style.display = 'none';
                a.innerText = '[Show EHV Panel]';
            }
        });
        $$('#searchbox>form>div')[1].append(a);
    } else {
        $('#ehv-panel-btn') && $('#ehv-panel-btn').remove();
    }
}

function setPanel() {
    const container = $('#f_search').parentNode;
    const searchInput = $('#f_search');

    // set style
    const panelCss = `#ehv-panel > input[type="button"]{ margin: 2px; }`;
    let panelStyle = $('#panel-style');
    if (!panelStyle) {
        panelStyle = create('style');
        panelStyle.id = 'panel-style';
        panelStyle.textContent = panelCss;
        document.head.append(panelStyle);
    }

    let ehvPanel = $('#ehv-panel');

    if (ehvPanel) ehvPanel.remove();

    ehvPanel = document.createElement('div');
    ehvPanel.setAttribute('class', 'nopm');
    ehvPanel.setAttribute('id', 'ehv-panel');
    ehvPanel.setAttribute('style', 'padding-top:8px;');
    container.append(ehvPanel);
    // filter buttons
    for (let key in data.custom_filter) {
        let filterBtn = create('input');
        filterBtn.setAttribute('type', 'button');
        filterBtn.setAttribute('value', key);
        filterBtn.setAttribute('title', data.custom_filter[key].toString());
        filterBtn.addEventListener('click', applyFilter);
        filterBtn.addEventListener('contextmenu', removeFilter);
        if (isExist(data.custom_filter[key])) {
            filterBtn.setAttribute('style', 'filter: invert(20%);');
        } else {
            filterBtn.removeAttribute('style');
        }
        ehvPanel.append(filterBtn);
    }
    //new filter button
    let addFilterBtn = create('input');
    addFilterBtn.setAttribute('type', 'button');
    addFilterBtn.setAttribute('value', '+');
    addFilterBtn.addEventListener('click', addFilter);
    addFilterBtn.addEventListener('contextmenu', ehvSetting);
    ehvPanel.append(addFilterBtn);

    // enhace apply filter button
    const searchBtn = $('#f_search+input');
    searchBtn.addEventListener('contextmenu', newtabSearch);
    searchBtn.title = 'right click to search in new page';

    // === function ===
    function isExist(values) {
        const inputValue = searchInput.value;
        return values.every(v => inputValue.includes(v));
    }
    function applyFilter(e) {
        let t = e.target;
        let key = t.value;
        let values = data.custom_filter[key];

        // add tailing space
        if(searchInput.value.length > 0 && searchInput.value.endsWith(' ') === false) searchInput.value += ' ';

        if (isExist(values)) {
            values.forEach(v => (searchInput.value = searchInput.value.replaceAll(`"${v}" `, '')));
            t.removeAttribute('style');
        } else {
            values.forEach(v => (searchInput.value += `"${v}" `));
            t.setAttribute('style', 'filter: invert(20%);');
        }
    }
    function addFilter() {
        data.custom_filter = GM_getValue('custom_filter', {}); // get latest filter data
        const input = prompt('Add filter like format below', '[tag] or [name@tag] or [name@tag+tag+tag+tag]');
        const filterStr = input ? input.split('@') : '';
        let key, value;
        if (filterStr.length === 2) {
            key = filterStr[0];
            value = filterStr[1].split('+');
            data.custom_filter[key] = value;
        } else if (filterStr.length === 1 && filterStr[0] !== '') {
            key = value = filterStr[0];
            data.custom_filter[key] = [value];
        } else {
            alert('Invalid input...:(');
            return;
        }
        // sort filters by char codepoint
        if(sortFilters === true){
            const newFilters = {};
            const sortedKeys = Object.keys(data.custom_filter).sort();
            for (let key of sortedKeys) {
                newFilters[key] = data.custom_filter[key];
            }
            data.custom_filter = newFilters;
        }
        GM_setValue('custom_filter', data.custom_filter);
        setPanel();
    }
    function ehvSetting(e) {
        e.preventDefault();
        data.script_config = GM_getValue('script_config', {}); // sync latest config data
        data.script_config.hide_panel = confirm('hide EHV panel by default?');
        data.script_config.hide_button = confirm(
            'hide comic page buttons by default? (hover lower right corner to show)'
        );
        GM_setValue('script_config', data.script_config);
        handleSearchBox();
    }
    function removeFilter(e) {
        e.preventDefault();
        data.custom_filter = GM_getValue('custom_filter', {}); // get latest filter data
        if (confirm('Delete this tag?')) {
            let key = e.target.value;
            delete data.custom_filter[key];
            GM_setValue('custom_filter', data.custom_filter);
            setPanel();
        }
    }
    function newtabSearch(e) {
        e.preventDefault();
        const keyword = e.target.previousElementSibling.value;
        const url = `https://${location.host}/?f_search=${keyword}`;
        open(url);
    }
}