booru navigational keybinds

Page navigation keyboard shortcuts for booru-type imageboards such as dervisions of Danbooru/Gelbooru, custom artist portals

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name            booru navigational keybinds
// @namespace       861ddd094884eac5bea7a3b12e074f34
// @version         3.7
// @description     Page navigation keyboard shortcuts for booru-type imageboards such as dervisions of Danbooru/Gelbooru, custom artist portals
// @author          Anonymous, Claude 4.5 Sonnet, GitHub Copilot (Claude 4.5 Haiku)
// @match           https://rule34.xxx/index.php?page=post&s=list*
// @match           https://rule34.xxx/index.php?page=favorites&s=view&id=*
// @match           https://rule34.xxx/index.php?page=comment&s=list*
// @match           https://yande.re/post
// @match           https://yande.re/post?tags=*
// @match           https://yande.re/post?page=*
// @match           https://yande.re/comment*
// @match           https://yande.re/note*
// @match           https://rule34.us/index.php?r=posts/index*
// @match           https://rule34.us/index.php?r=posts%2Findex*
// @exclude         https://rule34.us/index.php?r=posts/view*
// @match           https://rule34.us/index.php?r=favorites/view*
// @match           https://rule34.us/index.php?r=favorites%2Fview*
// @match           https://rule34.us/index.php?r=comments/list*
// @match           https://rule34.us/index.php?r=comments%2Flist*
// @match           https://gelbooru.com/index.php?page=post&s=list*
// @match           https://gelbooru.com/index.php?page=favorites&s=view&id=*
// @match           https://gelbooru.com/index.php?page=comment&s=list*
// @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://konachan.net/comment*
// @match           https://konachan.net/note*
// @match           https://kusowanka.com/
// @match           https://kusowanka.com/?page=*
// @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://*.booru.org/index.php?page=comment&s=list*
// @match           https://safebooru.org/index.php?page=post&s=list*
// @match           https://safebooru.org/index.php?page=favorites&s=view&id=*
// @match           https://safebooru.com/index.php?page=comment&s=list*
// @match           https://xbooru.com/index.php?page=post&s=list*
// @match           https://xbooru.com/index.php?page=favorites&s=view&id=*
// @match           https://xbooru.com/index.php?page=comment&s=list*
// @match           https://realbooru.com/index.php?page=post&s=list*
// @match           https://realbooru.com/index.php?page=favorites&s=view&id=*
// @match           https://realbooru.com/index.php?page=comment&s=list*
// @match           https://derpibooru.org/*
// @exclude         https://derpibooru.org/conversations*
// @exclude         https://derpibooru.org/profiles/*/reports*
// @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/conversations*
// @exclude         https://twiibooru.org/profiles/*/reports*
// @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/conversations*
// @exclude         https://manebooru.art/profiles/*/reports*
// @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://hypnohub.net/index.php?page=comment&s=list*
// @match           https://booru.eu/post/list*
// @match           https://booru.eu/comment/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/*
// @match           https://shimmie.shishnet.org/post/list*
// @match           https://shimmie.shishnet.org/comment/list*
// @match           https://furbooru.org/*
// @exclude         https://furbooru.org/conversations*
// @exclude         https://furbooru.org/profiles/*/reports*
// @exclude         https://furbooru.org/filters
// @exclude         https://furbooru.org/settings*
// @exclude         https://furbooru.org/*/new
// @exclude         https://furbooru.org/pages/*
// @exclude         https://furbooru.org/staff
// @match           https://e-shuushuu.net/
// @match           https://e-shuushuu.net/?page=*
// @match           https://e-shuushuu.net/search/results/*
// @match           https://e-shuushuu.net/top.php*
// @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.
//

//
// IMPLEMENTATION NOTES
// - tbib.org has no global comments list and doesn't seem to be derived from gelbooru
//   software anymore, despite diverging from the booru.org network.
//

//
// CHANGELOG
// v3.7         Enhancements and fixes
//              - Add site support for e-shuushuu.net
//              - Add site support for furbooru.org (another philomena instance)
//              - Fix site support for domains that have multi-part TLDs
// v3.6         Enhancements and fixes
//              - Add site support for shimmie testbed (for testing of latest shimmie2 code)
//              - Add page support for recent comments list on rule34.xxx, Gelbooru- and Danbooru-based sites
//              - Add page support for recent notes list on Danbooru-based sites (yande.re, kondachan.net)
//              - Add page support for booru-on-rails(-like) instances' activity listing (same as posts)
//              - Exclude additional pages for booru-on-rails(-like) instances
//              - Fix InkBunny search
//              - Fix frontpage kusowanka.com navigation
// 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 FurAffinity.net
//     (something weird going on where using gallery pagination buttons
//      break our keybinds. also, requires implementaiton of alternates
//      for route site types)
//   - Support luscious.net
//     (when infinite scroll is disabled)
//   - Support rule34.dev
//   - Support weasyl.com
//     (requires reading DOM to update cursor/ID offset)
//   - Support thehentaiworld.com
//     (something weird going on where our keybinds navigate to posts)
//   - Support W/D keybinds for sites that natively implement arrow keys
//     - rule34hentai.net
//     - kemono.cr
//     - rule34.paheal.net
//     - nozomi.la
//       (requires JS call to key_typed_in_body() for search page nav)
// - Nice-to-haves
//   - Add 'last page' detection
//   - 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.
//   - Refactor getAlternateMinimum() to return default site 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
// - Testing
//   - Disable "Booru Hotkeys" Firefox extension and check the full list of
//     presently planned & supported sites to see which *really* do support
//     primary and alternate nav keybinds

(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],
        'furbooru.org': ['page', 1],
        'e-shuushuu.net': ['page', 1],
    };
    // path exceptions to the site minimums above
    // { domain: [ [{ param key: param value }, minimum page # ], ... ]}
    const PAGE_PATHS = {
        'rule34.us': [
            [{ 'r': 'favorites/view' }, 0],
            [{ 'r': 'comments/list' }, 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,
    };
    // offset exceptions to the site minimums above
    // { domain: [{ param key: param value, ... }, items per page ]}
    const ALT_OFFSETS = {
        'rule34.xxx': [[{ 'page': 'comment', 's': 'list' }, 15]],
        'hypnohub.net': [[{ 'page': 'comment', 's': 'list' }, 10]],
        'safebooru.org': [[{ 'page': 'comment', 's': 'list' }, 10]],
        'xbooru.com': [[{ 'page': 'comment', 's': 'list' }, 10]],
        'realbooru.com': [[{ 'page': 'comment', 's': 'list' }, 10]],
        'gelbooru.com': [[{ 'page': 'comment', 's': 'list' }, 10]],
        'booru.org': [[{ 'page': 'comment', 's': 'list' }, 15]],
    }

    // for domains that put page count in a route (such as shimmie)
    // { domain: [minimum page, indexed page position, page param name] }
    const ROUTE_DOMAINS = {
        'booru.eu': [1, -1, ''],
        'hentai-foundry.com': [1, -1, 'page'],
        'shishnet.org': [1, -1, ''],
    }

    /// fallback hardcoding for if autodetection of site software fails
    // booru-on-rails and derivatives (philomena)
    const BOR_SITES = [
        'derpibooru.org',
        'twibooru.org',
        'manebooru.art',
        'furbooru.org',
    ];
    // rule34.world operations
    const R34WORLD_SITES = [
        'rule34vault.com',
        'rule34.xyz',
        'rule34.world'
    ];

    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_parts[0] === 'www') {
        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 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 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 getAlternateMinimum(config, site) {
        if (!(site in config)) return null;

        let response = null;

        // test each set of param combinations to alternates
        for (const element of config[site]) {
            const params = element[0];
            var isCompleteMatch = true;
            // test each set of param key-value pairs
            for (const [key, value] of Object.entries(element[0])) {
                if (!PARAMS.has(key) || PARAMS.get(key) !== value) {
                    isCompleteMatch = false;
                    break;
                }
            }

            if (!isCompleteMatch) {
                // if all sets fail, continue to next proposed alternate
                continue;
            } else {
                // otherwise, bubble up the alternate
                response = element[1];
                break;
            }
        }

        // no test needed since default is null
        return response;
    }

    function getCurrentPage(type) {
        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 = getAlternateMinimum(PAGE_PATHS, DOMAIN);
            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][1];
        }
    }

    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 = getAlternateMinimum(PAGE_PATHS, DOMAIN);
            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)) {
            // TODO: check ROUTE_DOMAINS
            if (current > 1) {
                return (current - 1);
            } else {
                if (DEBUG) console.debug(`(current=${current}) <= 1`);
                return false;
            }
        }
    }

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

    function updateQuery(type, dest) {
        const pageNo = dest.toString();
        if (type === 'pid') {
            // assume parameter is named 'pid'
            PARAMS.set(type, pageNo);
        } else if (type === 'page') {
            const pageKey = PAGE_DOMAINS[DOMAIN][0];
            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 (pathParts[pathIndex] === '') pathParts.pop();
                const pageRouteName = ROUTE_DOMAINS[DOMAIN][2];
                if (pageRouteName) pathParts.push(pageRouteName);
                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.startsWith('/activity'))
        ) 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(type, direction) {
        PATH = window.location.pathname;
        PARAMS = new URLSearchParams(window.location.search);

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

        let offset = null;
        if (type === 'pid') {
            const alternate = getAlternateMinimum(ALT_OFFSETS, DOMAIN);
            offset = (alternate !== null)
                ? alternate : PID_DOMAINS[DOMAIN];
            if (DEBUG) console.debug(`offset = ${offset}`);
        }

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

        updateQuery(type, dest);
        const newTrail = reformatUri().join('?');
        window.location.href = window.location.origin + newTrail;
        return true;
    }

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

    const SITE_TYPE = detectPaginationType();

    document.addEventListener('keydown', function(e) {
        // don't trigger on modifier combinations
        if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return;

        // don't trigger when an input field has focus
        if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)
            || e.target.getAttribute('role') == 'search' // zerochan
        ) return;

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

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

        if (typeof response === 'undefined' || !response) {
            console.error(
                'booru navigational keybinds: Keybind caught, but '
                + 'failed to navigate!'
            );
        }
    });

    if (isPathExcluded()) return false;

    if (!SITE_TYPE) {
        console.error('booru navigational keybinds: Failed to initialize!');
        return false;
    } else {
        console.log(
            `booru navigational keybinds loaded (in ${SITE_TYPE} mode): `
            + 'Use ← → arrow keys to navigate gallery pages.'
        );
    }
})();