booru keybinds

Keybinds for navigating boorus

2025-12-18 기준 버전입니다. 최신 버전을 확인하세요.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name            booru keybinds
// @namespace       861ddd094884eac5bea7a3b12e074f34
// @version         3.5.1
// @description     Keybinds for navigating boorus
// @author          Anonymous
// @match           https://rule34.xxx/index.php?page=post&s=list*
// @match           https://rule34.xxx/index.php?page=favorites&s=view&id=*
// @match           https://yande.re/post
// @match           https://yande.re/post?tags=*
// @match           https://yande.re/post?page=*
// @match           https://rule34.us/index.php?r=posts/index*
// @match           https://rule34.us/index.php?r=posts%2Findex*
// @match           https://rule34.us/index.php?r=favorites/view*
// @match           https://rule34.us/index.php?r=favorites%2Fview*
// @exclude         https://rule34.us/index.php?r=posts/view*
// @match           https://gelbooru.com/index.php?page=post&s=list*
// @match           https://gelbooru.com/index.php?page=favorites&s=view&id=*
// @match           https://www.zerochan.net/*
// @exclude         https://www.zerochan.net/pm*
// @exclude         https://www.zerochan.net/moe
// @exclude         https://www.zerochan.net/faq
// @exclude         https://www.zerochan.net/about
// @exclude         https://www.zerochan.net/privacy
// @exclude         https://www.zerochan.net/api
// @exclude         https://www.zerochan.net/upload3
// @exclude         https://www.zerochan.net/forum
// @exclude         https://www.zerochan.net/options*
// @exclude         https://www.zerochan.net/report*
// @match           https://anime-pictures.net/posts?page=*
// @match           https://anime-pictures.net/posts?favorite_by=*
// @match           https://anime-pictures.net/posts?user=*
// @match           https://anime-pictures.net/stars?page=*
// @match           https://konachan.net/post
// @match           https://konachan.net/post?tags=*
// @match           https://konachan.net/post?page=*
// @match           https://kusowanka.com/
// @match           https://kusowanka.com/search/*
// @match           https://kusowanka.com/tag*
// @match           https://kusowanka.com/parod*
// @match           https://kusowanka.com/artist*
// @match           https://kusowanka.com/character*
// @match           https://kusowanka.com/metadata*
// @match           https://tbib.org/index.php?page=post&s=list*
// @match           https://tbib.org/index.php?page=favorites&s=view&id=*
// @match           https://*.booru.org/index.php?page=post&s=list*
// @match           https://*.booru.org/index.php?page=favorites&s=view&id=*
// @match           https://safebooru.org/index.php?page=post&s=list*
// @match           https://safebooru.org/index.php?page=favorites&s=view&id=*
// @match           https://xbooru.com/index.php?page=post&s=list*
// @match           https://xbooru.com/index.php?page=favorites&s=view&id=*
// @match           https://realbooru.com/index.php?page=post&s=list*
// @match           https://realbooru.com/index.php?page=favorites&s=view&id=*
// @match           https://derpibooru.org/*
// @exclude         https://derpibooru.org/filters
// @exclude         https://derpibooru.org/settings*
// @exclude         https://derpibooru.org/*/new
// @exclude         https://derpibooru.org/pages/*
// @exclude         https://derpibooru.org/staff
// @match           https://twibooru.org/*
// @exclude         https://twibooru.org/filters
// @exclude         https://twibooru.org/settings*
// @exclude         https://twibooru.org/*/new
// @exclude         https://twibooru.org/pages/*
// @exclude         https://twibooru.org/staff
// @match           https://manebooru.art/*
// @exclude         https://manebooru.art/filters
// @exclude         https://manebooru.art/settings*
// @exclude         https://manebooru.art/*/new
// @exclude         https://manebooru.art/pages/*
// @exclude         https://manebooru.art/staff
// @match           http://browse.minitokyo.net/gallery*
// @match           http://gallery.minitokyo.net/*
// @match           https://inkbunny.net/submissionsviewall.php*
// @match           https://inkbunny.net/usersviewall.php*
// @match           https://inkbunny.net/gallery/*
// @match           https://inkbunny.net/scraps/*
// @match           https://hypnohub.net/index.php?page=post&s=list*
// @match           https://hypnohub.net/index.php?page=favorites&s=view&id=*
// @match           https://booru.eu/post/list*
// @match           https://rule34vault.com/*
// @exclude         https://rule34vault.com/trends*
// @exclude         https://rule34vault.com/comments*
// @exclude         https://rule34vault.com/u/*?tab=subs
// @exclude         https://rule34vault.com/u/*?tab=comments
// @exclude         https://rule34vault.com/account*
// @exclude         https://rule34vault.com/post/*
// @exclude         https://rule34vault.com/dmca
// @exclude         https://rule34vault.com/terms
// @exclude         https://rule34vault.com/contact-us
// @exclude         https://rule34vault.com/upgrade-to-premium
// @match           https://rule34.xyz/*
// @exclude         https://rule34.xyz/trends*
// @exclude         https://rule34.xyz/comments*
// @exclude         https://rule34.xyz/u/*?tab=subs
// @exclude         https://rule34.xyz/u/*?tab=comments
// @exclude         https://rule34.xyz/account*
// @exclude         https://rule34.xyz/post/*
// @exclude         https://rule34.xyz/dmca
// @exclude         https://rule34.xyz/terms
// @exclude         https://rule34.xyz/contact-us
// @exclude         https://rule34.xyz/upgrade-to-premium
// @match           https://rule34.world/*
// @exclude         https://rule34.world/trends*
// @exclude         https://rule34.world/comments*
// @exclude         https://rule34.world/u/*?tab=subs
// @exclude         https://rule34.world/u/*?tab=comments
// @exclude         https://rule34.world/account*
// @exclude         https://rule34.world/post/*
// @exclude         https://rule34.world/dmca
// @exclude         https://rule34.world/terms
// @exclude         https://rule34.world/contact-us
// @exclude         https://rule34.world/upgrade-to-premium
// @match           https://furry34.com/*
// @exclude         https://furry34.com/trends*
// @exclude         https://furry34.com/comments*
// @exclude         https://furry34.com/u/*?tab=subs
// @exclude         https://furry34.com/u/*?tab=comments
// @exclude         https://furry34.com/account*
// @exclude         https://furry34.com/post/*
// @exclude         https://furry34.com/dmca
// @exclude         https://furry34.com/terms
// @exclude         https://furry34.com/contact-us
// @exclude         https://furry34.com/upgrade-to-premium
// @match           https://www.hentai-foundry.com/categories/*/pictures*
// @match           https://www.hentai-foundry.com/pictures/recent*
// @match           https://www.hentai-foundry.com/pictures/featured*
// @match           https://www.hentai-foundry.com/pictures/popular*
// @match           https://www.hentai-foundry.com/pictures/random*
// @match           https://www.hentai-foundry.com/pictures/user/*
// @match           https://www.hentai-foundry.com/pictures/tagged/*
// @grant           none
// @homepageURL     https://greasyfork.org/en/scripts/558617-booru-keybinds
// @supportURL      https://greasyfork.org/en/scripts/558617-booru-keybinds/feedback
// @license         MIT-0
// ==/UserScript==

//
// FEATURES
// - Keyboard shortcuts:
//   - Use A/D or left/right arrow keys to navigate through pages instead of clicking buttons!
//   - Use W/S or up/down arrow keys for navigating through history.
// - Page types supported:
//   - Minimally works with browse and search page types across all supported sites.
//   - Extended page type support is spotty and may include user uploads/favorites and more.
//

//
// CHANGELOG
// v3.5.1       Enhancements
//              - Support InkBunny pages for user scraps
//              - Support W/S or up/down arrow keys for navigating through history
// v3.5         Support new sites and fix bugs
//              - Add support for hentai-foundry.com
//              - Fix zerochan navigation from frontpage
// v3.4.1       Various bugfixes
//              - Fix rule34vault.com and furry34.com support
//              - Fix rule34.us user favorites page nagivation
//              - Fix navigation for anime-pictures.net
//              - Optimize code with shorthand
// v3.4         Add A/D navigational keybind alternatives for 60% keyboard users
// v3.3         Add support for rule34vault.com, rule34.xyz, rule34.world, furry34.com
// v3.2         Various improvements
//              - Convert remaining URIs matched by regex to globular
//              - Add support for InkBunny user gallery pages
//              - Fix browser-native key events being caught and suppressed (e.g. alt+arrow)
// v3.1         Various improvements
//              - Auto-detect sites running booru-on-rails and derivatives
//              - Move to globular URI matching for more sites
//              - Support more pages across already supported sites
//              - Add in-code URI evaluation for enhanced exclusion logic e.g. zerochan post pages
// v3.0.1       Bugfixes, and improvements to Minitokyo handling
// v3.0         Add support for booru.eu
// v2.6         Add support for minitokyo.net, inkbunny.net, and hypnohub.net
// v2.4         Add support for derpibooru.org, twibooru.org, and manebooru.art
// v2.3         Add support for tbib.org, safebooru.org, xbooru.com, realbooru.com, and booru.org
// v2.2         Add support for zerochan.net, anime-pictures.net, konachan.net, and kusomanka.com
// v2.1         Add support for rule34.us and gelbooru.com
// v2.0         Add support for yande.re
// v1.0         Initial release with support for rule34.xxx
//

//
// TODO
// - Functional enhancements
//   - Support weasyl.com and FurAffinity.net
//   - Support rule34.xxx comment list page
//     https://rule34.xxx/index.php?page=comment&s=user&user=* (pid offset of 15)
//   - Add up arrow and W keybinds for navigating 'back' through history to search results when on post detail page.
// - Nice-to-haves
//   - Add 'last page' detection logic to prevent inappropriate forward navigation.
//   - Briefly change the cursor to visually indicate when a requested nav event fails or is rejected.
// - Refinement and refactoring
//   - Rewrite 'gallery' page type into abstraction supporting multiple page route exceptions similar to PAGE_PATHS,
//     but for path index instead of page minimum.
//   - zerochan and Hentai-Foundry post pages ought to be excluded
//     https://www.zerochan.net/[1-9][0-9]+
//     https://www.hentai-foundry.com/pictures/user/USERNAME/POST_NUMBER/POST_TITLE
//

(function() {
    'use strict';

    const DEBUG = false;

    // for domains that use page numbers in an url parameter
    // { domain: [ parameter, first page] }
    const PAGE_DOMAINS = {
        'yande.re': ['page', 1],
        'rule34.us': ['page', 1],
        'zerochan.net': ['p', 1],
        'anime-pictures.net': ['page', 0],
        'konachan.net': ['page', 1],
        'kusowanka.com': ['page', 1],
        'derpibooru.org': ['page', 1],
        'twibooru.org': ['page', 1],
        'manebooru.art': ['page', 1],
        'minitokyo.net': ['page', 1],
        'inkbunny.net': ['page', 1],
        'rule34vault.com': ['page', 1],
        'rule34.xyz': ['page', 1],
        'rule34.world': ['page', 1],
        'furry34.com': ['page', 1],
    };
    // path exceptions to the site minimums above
    const PAGE_PATHS = {
        'rule34.us': [
            { 'r': 'favorites/view' }, 0
        ]
    }
    // for domains that put item count in an url parameter
    // { domain: items per page }
    const PID_DOMAINS = {
        'rule34.xxx': 42,
        'gelbooru.com': 42,
        'tbib.org': 42,
        'booru.org': 20,
        'safebooru.org': 42,
        'xbooru.com': 42,
        'realbooru.com': 42,
        'hypnohub.net': 42
    };
    // for domains that put page count in a route
    // { domain: minimum page number, indexed page position }
    const ROUTE_DOMAINS = {
        'booru.eu': [1, -1],
        'hentai-foundry.com': [1, -1],
    }
    // booru-on-rails and derivatives (philomena)
    const BOR_SITES = ['derpibooru.org', 'twibooru.org', 'manebooru.art'];
    // rule34.world operations
    const R34WORLD_SITES = ['rule34vault.com', 'rule34.xyz', 'rule34.world'];

    let DEBUG_TEXT = [];
    let PATH = window.location.pathname;
    let PARAMS = new URLSearchParams(window.location.search);
    const FQDN = window.location.hostname;
    let DOMAIN = FQDN;
    const domain_parts = FQDN.split('.');
    if (domain_parts.length >= 3) {
        DOMAIN = (
            domain_parts[domain_parts.length - 2]
            + '.' + domain_parts[domain_parts.length - 1]
        );
    }

    // .startsWith() but accepting an array of strings to check
    function startsWithMany(str, arr) {
        return arr.some(s => str.startsWith(s));
    }

    function isPathExcluded() {
        // in place of using regex for a host match exclusion,
        // ^https:\/\/www\.zerochan\.net\/[1-9][0-9]+
        if (DOMAIN === 'zerochan.net'
            && PATH.substring(1)
            && !isNaN(parseInt(PATH.substring(1)))
        ) {
            return true;
        }
        return false;
    }

    function getAlternateMinimumPage() {
        // Check if current domain/path matches PAGE_PATHS config items
        if (DOMAIN in PAGE_PATHS) {
            const pathMatch = PAGE_PATHS[DOMAIN][0];
            const alternateMin = PAGE_PATHS[DOMAIN][1];
            for (const [key, value] of Object.entries(pathMatch)) {
                if (!PARAMS.has(key) || PARAMS.get(key) !== value) {
                    return null;
                }
            }
            return alternateMin;
        }
        return null;
    }

    function detectPaginationType() {
        if (DOMAIN === 'inkbunny.net'
            && startsWithMany(PATH, ['/gallery/', '/scraps/'])
        ) {
            return 'gallery';
        } else if (DOMAIN in PID_DOMAINS) {
            return 'pid';
        } else if (DOMAIN in PAGE_DOMAINS) {
            return 'page';
        } else if (DOMAIN in ROUTE_DOMAINS) {
            return 'route';
        }
        return null;
    }

    function getCurrentPage(type) {
        if (!type) type = detectPaginationType();
        if (type === 'pid') {
            // assume parameter is named 'pid' and first item is 0
            return parseInt(
                PARAMS.get(
                    type
                )) || 0;
        } else if (type === 'page') {
            // retrieve parameter name from configuration
            // if no such param was passed, return known first page for domain
            const alternateMin = getAlternateMinimumPage();
            const defaultMin = (alternateMin !== null)
                ? alternateMin : PAGE_DOMAINS[DOMAIN][1];
            return parseInt(
                PARAMS.get(PAGE_DOMAINS[DOMAIN][0])
            ) || defaultMin;
        } else if (type === 'route') {
            let pathParts = PATH.split('/');
            const defaultMin = ROUTE_DOMAINS[DOMAIN][0];
            const pathOffset = ROUTE_DOMAINS[DOMAIN][1];
            let pathIndex = (pathOffset < 0)
                ? pathParts.length + pathOffset
                : pathOffset;
            const current = parseInt(pathParts[pathIndex]);
            if (isNaN(current)) {
                return defaultMin;
            } else {
                return current;
            }
        } else if (type === 'gallery') {
            // special snowflake inkbunny puts its page number at position -2
            // e.x. /pageName/USERNAME/PAGE/HASH
            let pathParts = PATH.split('/');
            return parseInt(
                pathParts[pathParts.length - 2]
            ) || PAGE_DOMAINS[DOMAIN];
        }
    }

    function calculatePreviousPage(type, current, pageLength) {
        if (type === 'pid') {
            if (current > 0) {
                return Math.max(0, current - pageLength);
            } else {
                if (DEBUG) console.debug(`(current=${current}) <= 0`);
                return false;
            }
        } else if (type === 'page') {
            const alternateMin = getAlternateMinimumPage();
            const minPage = (alternateMin !== null)
                ? alternateMin : PAGE_DOMAINS[DOMAIN][1];
            if (current > minPage) {
                return (current - 1);
            } else {
                if (DEBUG) console.debug(`(current=${current}) <= (minPage=${minPage})`);
                return false;
            }
        } else if (['route', 'gallery'].includes(type)) {
            if (current > 1) {
                return (current - 1);
            } else {
                if (DEBUG) console.debug(`(current=${current}) <= 1`);
                return false;
            }
        }
    }

    function calculateNextPage(type, current, pageLength) {
        if (type === 'pid') {
            return (current + pageLength);
        } else if (['page', 'route', 'gallery'].includes(type)) {
            return (current + 1);
        }
    }

    function updateQuery(type, dest) {
        const pageNo = dest.toString();
        const pageKey = PAGE_DOMAINS[DOMAIN][0];
        if (type === 'pid') {
            // assume parameter is named 'pid'
            PARAMS.set(type, pageNo);
        } else if (type === 'page') {
            PARAMS.set(pageKey, pageNo);
        } else if (type === 'route') {
            let pathParts = PATH.split('/');
            const pathOffset = ROUTE_DOMAINS[DOMAIN][1];
            let pathIndex = (pathOffset < 0)
                ? pathParts.length + pathOffset
                : pathOffset;
            if (!isNaN(parseInt(pathParts[pathIndex]))) {
                pathParts[pathIndex] = pageNo;
            } else {
                if (DOMAIN === 'hentai-foundry.com') pathParts.push('page');
                pathParts.push(pageNo);
            }
            PATH = pathParts.join('/');
        } else if (type === 'gallery') {
            // for /pageName/USERNAME/PAGE/HASH, replace PAGE at position -2
            let pathParts = PATH.split('/');
            pathParts[pathParts.length - 2] = pageNo;
            PATH = pathParts.join('/');
        }
        return true;
    }

    function reformatUri() {
        // redirect booru-on-rails frontpage to listing page on navigation
        const footer = document.querySelector('#serving_info');
        if (((typeof BOR_SITES === 'object'
                && BOR_SITES.includes(DOMAIN))
            || (footer !== null
                && footer.tagName === 'DIV'
                && (footer.textContent.includes('booru-on-rails')
                    || footer.textContent.includes('philomena'))))
            && PATH === '/'
        ) PATH = (DOMAIN == 'twibooru.org') ? '/posts' : '/images';

        // remove unsupported query string parameters
        // that break navigation when we alter the page param
        if ((typeof R34WORLD_SITES === 'object'
                && R34WORLD_SITES.includes(DOMAIN))
            && PARAMS.has('cursor')
        ) PARAMS.delete('cursor');

        // if there are parameters and we've modified them,
        // reconstruct the search string
        return (PARAMS.size)
            ? `${PATH}?${PARAMS.toString()}`
            : PATH;
    }

    function navigate(direction) {
        PATH = window.location.pathname;
        PARAMS = new URLSearchParams(window.location.search);

        const type = detectPaginationType();
        let current = null;
        current = getCurrentPage(type);
        if (DEBUG) console.log(`currentPage = ${current}`);

        let pageLength = null;
        if (type === 'pid') pageLength = PID_DOMAINS[DOMAIN];

        let dest = null;
        if (direction === 'previous') {
            dest = calculatePreviousPage(type, current, pageLength);
        } else if (direction === 'next') {
            dest = calculateNextPage(type, current, pageLength);
        }
        // test specifically for false (0 is a valid page number!!)
        if (dest !== 0 && !dest) return false;

        updateQuery(type, dest);

        if (DEBUG) {
            DEBUG_TEXT.push(
                `Keybind caught: direction=${direction}, type=${type}, `
                + `current=${current}, dest=${dest}, pageLength=${pageLength}, `
                + `PARAMS=${PARAMS.toString()}, PATH=${PATH}`
            );
        }

        PATH = reformatUri();
        window.location.href = PATH;
    }

    // function isKeybindExcluded(key) {
    //     if ((['gelbooru.com', 'booru.org'].includes(DOMAIN))
    //         && (key == 'a' || key == 'd')
    //     ) {
    //         return true;
    //     } else {
    //         return false;
    //     }
    // }

    // activation conditions
    if (isPathExcluded()) return false;
    const type = detectPaginationType();
    if (!type) return false;

    document.addEventListener('keydown', function(e) {
        // ignore modifiers
        if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return;

        // ignore keypress when input field is focused
        if (
            e.target.tagName === 'INPUT'
            || e.target.tagName === 'TEXTAREA'
            || e.target.getAttribute('role') == 'search'
        ) return;

        // ignore keys on sites that partially implement our bindings
        // NOTE: native navigation via these keybinds is broken XD
        // if (isKeybindExcluded(e.key.toLowerCase())) return;

        const key = (e.key || '').toLowerCase();
        if (key === 'arrowleft' || key === 'a') {
            e.preventDefault();
            navigate('previous');
        } else if (key === 'arrowright' || key === 'd') {
            e.preventDefault();
            navigate('next');
        } else if (key === 'arrowup' || key === 'w') {
            e.preventDefault()
            history.back();
        } else if (key === 'arrowdown' || key === 's') {
            e.preventDefault()
            history.forward();
        }
    });

    console.log(`Keybinds loaded (${type} mode). Use ← → arrow keys to navigate gallery pages.`);
    if (DEBUG && DEBUG_TEXT) console.debug(DEBUG_TEXT.join('\n'));
})();