booru keybinds

Keybinds for navigating boorus

Ekde 2025/12/12. Vidu La ĝisdata versio.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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            booru keybinds
// @namespace       861ddd094884eac5bea7a3b12e074f34
// @version         3.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=*
// @include         /^https:\/\/yande\.re\/post(\?page=[1-9]+)?([&?]tags=[^&]+)?/
// @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*
// @include         /^https:\/\/rule34\.us\/index\.php\?r=(posts|favorites)(%2F|\/)(index|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=*
// @include         /^https:\/\/konachan\.net\/post(\?page=[1-9]+)?([&?]tags=[^&]+)?/
// @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://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*
// @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
// - Use left/right arrow keys to navigate back and forward instead of clicking pagination buttons!
// - Minimally supports browse and search page types.
// - Extended page type support is spotty and may include user uploads/favorites and more.
// - Somewhat intelligent behavior!
//   - Avoids navigation when user has input fields focused.
//   - Avoids inappropriate back navigation on first pages, homepages, etc.
//

//
// CHANGELOG
// 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
//              - Improve readability of code
// 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
// - Remaining globular URI match items to migrate; custom URI evaluation may be needed
// - 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.
// - Add support for InkBunny user gallery pages; /gallery/USERNAME/PAGE/HASH
// - rule34.us needs special handling for user favorites: ?page=1 doesn't work and must be removed.
//

(function() {
    'use strict';

    // 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]
    };
    // 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
    };
    // booru-on-rails and derivatives (philomena)
    const BOR_SITES = [
        'derpibooru.org',
        'twibooru.org',
        'manebooru.art'
    ];
    const DEBUG = false;

    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]
        );
    }

    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 detectPaginationType() {
        if (DOMAIN in PID_DOMAINS) {
            return 'pid';
        } else if (DOMAIN in PAGE_DOMAINS) {
            return 'page';
        } else if (PATH.startsWith('/post/list')) {
            return 'path';
        }
        return null;
    }

    function getCurrentPage(type) {
        let page;
        if (!type) type = detectPaginationType();
        if (type === 'pid') {
            // assume parameter is named 'pid' and first item is 0
            page = parseInt(
                PARAMS.get(
                    type
                )) || 0;
        } else if (type === 'page') {
            // special case for zerochan frontpage
            if (
                DOMAIN == 'www.zerochan.net'
                && PATH === '/'
                && !PARAMS.has(PAGE_DOMAINS[DOMAIN][0])
            ) {
                page = 0;
            } else {
                // retrieve parameter name from configuration
                // if no such param was passed, return known first page for domain
                page = parseInt(
                    PARAMS.get(
                        PAGE_DOMAINS[DOMAIN][0]
                    )) || PAGE_DOMAINS[DOMAIN][1];
            }
        } else if (type === 'path') {
            let path_parts = PATH.split('/');
            if (path_parts.length >= 4) {
                page = parseInt(
                    path_parts[path_parts.length - 1]
                ) || 1;
            } else {
                page = 1;
            }
        }
        return page;
    }

    function calculatePreviousPage(type, current, pageLength) {
        if (type === 'pid') {
            if (current > 0) {
                return Math.max(0, current - pageLength);
            } else {
                if (DEBUG) console.log(`(current=${current}) <= 0`);
                return false;
            }
        } else if (type === 'page') {
            if (current > PAGE_DOMAINS[DOMAIN][1]) {
                return (current - 1);
            } else {
                if (DEBUG) console.log(`(current=${current}) <= (PAGE_DOMAINS[DOMAIN][1]=${PAGE_DOMAINS[DOMAIN][1]})`);
                return false;
            }
        } else if (type === 'path') {
            if (current > 1) {
                return (current - 1);
            } else {
                if (DEBUG) console.log(`(current=${current}) <= 1`);
                return false;
            }
        }
    }

    function calculateNextPage(type, current, pageLength) {
        if (type === 'pid') {
            return (current + pageLength);
        } else if (type === 'page' || type === 'path') {
            return (current + 1);
        }
    }

    function updateQuery(type, dest) {
        if (type === 'pid') {
            // assume parameter is named 'pid'
            PARAMS.set(type, dest);
        } else if (type === 'page') {
            PARAMS.set(PAGE_DOMAINS[DOMAIN][0], dest);
        } else if (type === 'path') {
            let path_parts = PATH.split('/');
            if (path_parts.length >= 4) path_parts.pop();
            path_parts.push(dest.toString());
            PATH = path_parts.join('/');
        }
    }

    function reformatUri() {
        // redirect frontpage to proper listing page for booru-on-rails sites
        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 === '/'
        ) {
            if (DOMAIN == 'twibooru.org') {
                PATH = '/posts';
            } else {
                PATH = '/images';
            }
        }

        // if there are parameters and we've modified them,
        // reconstruct the search string
        let params_str = PARAMS.toString();
        if (PARAMS.size
            // && params_str !== window.location.search
        ) {
            return `${PATH}?${params_str}`;
        } else {
            return PATH;
        }
    }

    function navigate(direction) {
        PARAMS = new URLSearchParams(window.location.search);
        const type = detectPaginationType();
        let current = null;
        current = getCurrentPage(type);

        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);
        }
        if (dest === false || dest === null) 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;
    }

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

    document.addEventListener('keydown', function(e) {
        // ignore keybind when input field is focused
        if (
            e.target.tagName === 'INPUT'
            || e.target.tagName === 'TEXTAREA'
            || e.target.getAttribute('role') == 'search'
        ) return;

        switch(e.key) {
            case 'ArrowLeft':
                e.preventDefault();
                navigate('previous');
                break;
            case 'ArrowRight':
                e.preventDefault();
                navigate('next');
                break;
        }
    });

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