Proxified Links

Proxified hyperlinks to a proxy instance or Farside with no nonsense

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name        Proxified Links
// @author      proxi
// @homepageURL https://greasyfork.org/en/scripts/485274-proxified-links
// @copyright   2023 Schimon Jehudah (http://schimon.i2p)
// @license     AGPL-3.0-only; https://www.gnu.org/licenses/agpl-3.0.en.html
// @namespace   com.proxi.proxified
// @description Proxified hyperlinks to a proxy instance or Farside with no nonsense
//              Add or remove preferred services and instances yourself!
//
//              Forked from Proxify Links v23.10.17, by Schimon Jehudah, and modified with prejudice and style
//              - Proxy, proxy, proxy; add or configure non-proxying frontends yourself with discretion
//              - No probing HTTP requests to proxies, use a small hardcoded list of favorites, or
//                hold X key to use Farside redirection where possible
//              - Hold Z key to use the original link whenever needed
//              - Coexist with first-party frontends that can be used with discretion,
//                meaning don't mess with the official site by linking out to a proxy of itself!
//              - Ignore search engines and leave it to user browser and search configuration,
//                don't play around with nonexistent LMGTFY links or broken Google scrapers
//              - Clearnet by default following Farside, add personal lists of decentralized nodes
//              - Set noreferrer on supported links, optionally all links if `ENABLE_REFERER_HIDE_PAGEWIDE`,
//                preventing referer header while browsing without breaking common ajax functionality
// @run-at      document-end
// @version     0.2.3
// @match       *://*/*
// @icon        data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgOTAwMCA5MDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InllbGxvdyIvPjxwYXRoIGQ9Ik0uMjc5LS4xNDYuMTUgMGgtLjE3MmwuMjQtLjI2Ni0uMTMyLS4yNTNoLjE0OWwuMDc2LjE0OC4xMzEtLjE0OGguMTdsLS4yNC4yNjkuMTMuMjVILjM1NEwuMjc5LS4xNDZaIiBzdHlsZT0iZmlsbDp3aGl0ZTsiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDE2NzUgNzcyOCkgc2NhbGUoODAwMCkiLz48cGF0aCBkPSJNLjE4OCAwSC4wNDFMLjE5LS43MTZoLjI5MWEuMzQuMzQgMCAwIDEgLjEyMy4wMThjLjAzLjAxMy4wNTQuMDMzLjA3Mi4wNjFhLjE5Mi4xOTIgMCAwIDEgLjAyNi4xMDEuMjczLjI3MyAwIDAgMS0uMDIxLjEwNS4yNC4yNCAwIDAgMS0uMDUxLjA4Mi4yMzcuMjM3IDAgMCAxLS4xNjEuMDcxIDEuMTY2IDEuMTY2IDAgMCAxLS4xMy4wMDVILjI0NUwuMTg4IDBaTS4yNy0uMzkyaC4wNDVhLjUzLjUzIDAgMCAwIC4xNTYtLjAxNC4xMzEuMTMxIDAgMCAwIC4wNjEtLjA0Ny4xMjMuMTIzIDAgMCAwIC4wMjMtLjA3MS4wNy4wNyAwIDAgMC0uMDEyLS4wNDIuMDYyLjA2MiAwIDAgMC0uMDMyLS4wMjUuMzU4LjM1OCAwIDAgMC0uMDkzLS4wMDdILjMxM0wuMjctLjM5MloiIHRyYW5zZm9ybT0ibWF0cml4KDkzMjAgMCAwIDkzMTMgMTEyOCA3NzI4KSIvPjxwYXRoIGQ9Im0yNTEuNjg0IDY1NS45ODkuMTYyLjIzIDUyMi40ODYgMTczLjI5Mi0xMjEuMjk4LTE3My4zNTItLjUwMy0uMTdIMjUxLjY4NFptMTM0OS40NzYgMzE4LjYxLTI4My45OS05NS4wMTZjLTY0LjE5LS45NjItMTQ4LjMyLTEuNDQzLTI1Mi4zOS0xLjQ0M0g4MDguMzU5bC0xNy43MjMtMjUuMzI4LTIxMi4wNTQgMTIxLjc4N2gzNDYuMzg0bDEwMi4wODQtNTkuNzM3IDE3Ny4xNiA1OS43MzdoMzk2Ljk1WiIgc3R5bGU9ImZpbGw6eWVsbG93Z3JlZW4iIHRyYW5zZm9ybT0ibWF0cml4KDMuNDMgMCAwIC0xMS40NCA2NDIgMTUyMzUuNikiLz48cGF0aCBkPSJNMTUwMCA4MTIwaDU4MDB2MzYwSDE3NTB6Ii8+IDwvc3ZnPg==
// ==/UserScript==

/**
 * Basic configurations for functionality and performance
 */
const KEY_MODIFIED = 'x'; // X key to modify clicked link to Farside, if applicable
const KEY_BONAFIDE = 'z'; // Z key to revert clicked link to the original destination
const TOUCH_MODIFIED = 3; // Simple 3-finger tap gesture to modify tapped link to Farside, if applicable
const TOUCH_BONAFIDE = 5; // Simple 5-finger tap gesture to revert tapped link to the original destination
const TOUCH_PROXIFIED = 2; // Reserved 2-finger tap gesture to reset tapped link to static proxified

// Optional keys to limit unintentional triggering on non-links
// Set to empty '' string, proxification will happen automatically on selection
// TODO: improve touch support
const KEY_PROXIBITE = 'b'; // Optional B key to allow heavier elements to be proxified
const KEY_FRAMEBITE = 'f'; // Optional B+F key combination to trigger when iframes are updated
const TOUCH_PROXIBITE = 4; // Simple 3-finger tap gesture to allow all heavier elements to be proxified
const TOUCH_FRAMEBITE = 4;
const KEY_NAVIGATE = ''; // Optional filter for key nav used to proxify the selected element, e.g Tab

// Optional proxified iframes with supported src, proxified immediately when selected
// Best used if iframe content is already blocked by a content blocker (e.g. ublock click2load),
// so the original frame is never loaded automatically even if switching back from proxified (B+F+Z)
const ENABLE_IFRAME_PROXIFIED = true;

// Optional noreferer override on all proxified links
// If disabled, only upgrade undefined or noopener to noreferrer for proxified links
// Disable if proxified but bona fide (Z) links are breaking
const ENABLE_REFERER_HIDE = true;

// Optional noreferrer override on all links on the page
// Disable if non-proxified links or site authentication windows are breaking
const ENABLE_REFERER_HIDE_PAGEWIDE = true;

// Optional proxified links using query string if the main url does not match a proxy
// Useful for tracking links or old-fashioned HTML GET search, e.g. DuckDuckGo (uddg)
// Strongly recommend used with `ENABLE_REFERER_HIDE_PAGEWIDE` to avoid exposing search engine and keywords
// Disable if random links that shouldn't be proxified still are
const ENABLE_QUERY_PROXIFIED = true;
const ENABLE_QUERY_PROXIFIED_ON = []; // if empty, all query parameters will be included

// Optional stripping links of attributes that are necessary to proxify links (Google),
// and/or optional removal of link trackers on sites or search engines
const ENABLE_ATTRIBUTES_SMITE = true;
/** @type {Object<string, {attributes: string[], allowFuzzy?: boolean}>} */
const ENABLE_ATTRIBUTES_SMITE_ON_SITE = { 'google.com': { attributes: ['data-sb'] } };

/**
 * Configure site settings or add an instance of a proxy to the site's `redirect` list
 *
 * As is, use a shortlist of useful instances and mature proxy services
 * Out of box is an opinionated, not comprehensive or up-to-date, list of useful instances,
 * not ordered alphabetically but in the order of best proxied or most commonly linked
 *
 * @typedef {{
 *  redirect?: {
 *    replacements?: string[],
 *    suffix?: string, // optional suffix to a random replacement host from `replacements`
 *    routes?: ProxyRoute[], // optional route whitelist by matching url regex, defaults to replacing url host only if undefined
 *    inherit?: string // inherit any undefined properties in `redirect`
 *  },
 *  redirectToFarside?: {
 *    replacements?: string[],
 *    suffix?: string, // optional suffix to a random replacement host from `replacements`
 *    routes?: ProxyRoute[], // optional route whitelist by matching url regex, defaults to replacing url host only if undefined
 *    inherit?: string // inherit any undefined properties in `redirectToFarside`
 *  },
 *  allowFuzzy?: boolean, // allows fuzzy host matching, for subdomains
 *  allowIframe?: boolean, // allows matching on iframe src
 *  inherit?: string // inherit any undefined redirect rules
 * }} Proxy
 * @typedef {{
 *    regex: RegExp, // route regex match
 *    suffix?: string, // optional route suffix on host, appending to parent `suffix` if defined
 * }} ProxyRoute
 * @typedef {Object<string, Proxy>} ProxyList
 */

/**
 * @type {ProxyList}
 */
const PROXIES = {
  // youtube.com, youtu.be, m.youtube.com, youtube-nocookie.com
  // /watch, /trending, /@, /channel/
  // Allows iframe matching too, which is useful when content blocking embeds
  //  e.g. ublock ||youtube.com^$3p,frame,redirect=click2load.html
  'youtube.com': {
    allowIframe: true,
    redirect: {
      replacements: [
        'https://piped.video',
        'https://piped.smnz.de',
        'https://piped.projectsegfau.lt',
        'https://piped.privacydev.net',
        //'https://piped.lunar.icu', // embedded frame blocked by x-frame-options
        'https://piped.adminforge.de',
        'https://pd.vern.cc',
        // The following Invidious instances not only allow video proxy but proxy by default
        'https://iv.datura.network',
        'https://invidious.projectsegfau.lt',
        'https://invidious.fdn.fr',
      ],
    },
    redirectToFarside: {
      // Only use Farside's Piped redirect since most Invidious instances do not proxy videos by default
      // Aside from proxy by default, Invidious is preferred for nojs, configuration, and download functionality
      // Specific Invidious instances that proxy by default are included in the instance `redirect` list
      replacements: ['https://farside.link/piped'],
    },
  },
  'youtu.be': {
    redirect: {
      inherit: 'youtube.com',
      routes: [
        {
          regex: /^https?:\/\/(www\.)?youtu\.be\/([A-Za-z0-9_-]+)\??(.*)$/,
          suffix: '/watch?v=$2&$3', // manually add in path and params to support invidious https://github.com/iv-org/invidious/issues/3933
        },
      ],
    },
    redirectToFarside: {
      inherit: 'youtube.com',
      routes: [
        {
          regex: /^https?:\/\/(www\.)?youtu\.be\/([A-Za-z0-9_-]+)\??(.*)$/,
          suffix: '/watch?v=$2&$3', // manually add in path and params to support invidious https://github.com/iv-org/invidious/issues/3933
        },
      ],
    },
  },
  'm.youtube.com': { inherit: 'youtube.com' },
  'youtube-nocookie.com': { inherit: 'youtube.com' },

  // reddit.com
  'reddit.com': {
    redirect: {
      replacements: [
        //'https://libreddit.projectsegfau.lt', // low availability
        'https://libreddit.privacydev.net',
        'https://l.opnxng.com',
        //'https://reddit.invak.id', // down
        'https://libreddit.kavin.rocks',
      ],
    },
    redirectToFarside: {
      // teddit no longer actively maintained: https://codeberg.org/teddit/teddit
      replacements: ['https://farside.link/libreddit'],
    },
  },
  // redd.it image shortlinks
  'i.redd.it': {
    redirect: {
      inherit: 'reddit.com',
      suffix: '/img',
    },
    redirectToFarside: {
      inherit: 'reddit.com',
      suffix: '/img',
    },
  },
  'preview.redd.it': {
    redirect: {
      inherit: 'reddit.com',
      suffix: '/preview/pre',
    },
    redirectToFarside: {
      inherit: 'reddit.com',
      suffix: '/preview/pre',
    },
  },
  'external-preview.redd.it': {
    redirect: {
      inherit: 'reddit.com',
      suffix: '/preview/external-pre',
    },
    redirectToFarside: {
      inherit: 'reddit.com',
      suffix: '/preview/external-pre',
    },
  },

  /**
   *
   * Below are less mature or partially featured services
   *
   */

  // stackoverflow.com, {subdomain}.stackexchange.com
  // TBD: superuser.com and other stack sites pending AnonymousOverflow support
  'stackoverflow.com': {
    redirect: {
      replacements: [
        'https://ao.vern.cc',
        'https://overflow.smnz.de',
        'https://overflow.lunar.icu',
        'https://overflow.adminforge.de',
        //'https://overflow.hostux.net', // low stability
        // 'https://overflow.projectsegfau.lt', // low availability
      ],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/anonymousoverflow'],
    },
  },
  '.stackexchange.com': {
    allowFuzzy: true, // enable matching loosely with arbitrary subdomain
    redirect: {
      inherit: 'stackoverflow.com',
      routes: [
        {
          regex: /^https?:\/\/(www\.)?([a-z]+)\..*?\//g,
          suffix: '/exchange/$2/', // suffix /exchange/{subdomain} on hostname
        },
      ],
    },
    redirectToFarside: {
      inherit: 'stackoverflow.com',
      routes: [
        {
          regex: /^https?:\/\/(www\.)?([a-z]+)\..*?\//g,
          suffix: '/exchange/$2/',
        },
      ],
    },
  },

  // quora.com
  'quora.com': {
    redirect: {
      replacements: ['https://quetre.iket.me', 'https://quetre.pussthecat.org', 'https://quetre.privacydev.net'],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/quetre'],
    },
  },

  // {artist}.bandcamp.com
  // Note: bandcamp.com/search route not supported, add above for 'bandcamp.com' if this rare link is needed is the wild
  // Note: {cdn}.bcbits.com routes not supported, add below for '.bcbits.com' if this rare link is needed in the wild
  '.bandcamp.com': {
    allowFuzzy: true,
    redirect: {
      replacements: ['https://tent.sny.sh', 'https://tn.vern.cc'],
      routes: [
        {
          // {artist}.bandcamp.com with no additional path except optional /music
          // exclude daily.bandcamp.com
          regex: /^https?:\/\/(www\.)?((?!daily\.)[a-z0-9\-]+)\.bandcamp\.com\/?(music)?$/g,
          suffix: '/artist.php?name=$2',
        },
        {
          // {artist}.bandcamp.com/{release}/{name}
          // exclude daily.bandcamp.com, e.g. daily.bandcamp.com/features/{article}
          regex: /^https?:\/\/(www\.)?((?!daily\.)[a-z0-9\-]+)\.bandcamp\.com\/([a-z]+)\/([a-z0-9\-]+)/g,
          suffix: '/release.php?artist=$2&type=$3&name=$4',
        },
      ],
    },
  },

  // instagram.com
  // Low feature parity
  'instagram.com': {
    redirect: {
      replacements: ['https://ig.opnxng.com', 'https://proxigram.lunar.icu'],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/proxigram'],
    },
  },

  // tiktok
  // Low feature parity
  'tiktok.com': {
    redirect: {
      replacements: [
        'https://proxitok.pussthecat.org',
        'https://tok.artemislena.eu',
        'https://tok.adminforge.de',
        'https://tik.hostux.net',
        'https://proxitok.lunar.icu',
      ],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/proxitok'],
    },
  },

  // imgur.com, i.imgur.com, i.stack.imgur.com
  'imgur.com': {
    redirect: {
      replacements: [
        'https://rimgo.pussthecat.org',
        'https://imgur.artemislena.eu',
        'https://rimgo.vern.cc',
        'https://rimgo.hostux.net',
        'https://rimgo.lunar.icu',
        //'https://rimgo.projectsegfau.lt', // low availability
      ],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/rimgo'],
    },
  },
  'i.imgur.com': { inherit: 'imgur.com' },
  'i.stack.imgur.com': {
    inherit: 'imgur.com',
    redirect: { inherit: 'imgur.com', suffix: '/stack' },
    redirectToFarside: { inherit: 'imgur.com', suffix: '/stack' },
  },

  // github.com, gists.github.com
  // /explore, /{group}/{repo}, /{group}/{repo}/archive, gists.github.com -> /gists/
  // Low feature parity
  // Use only for repo landing page, downloads, and gists
  'github.com': {
    redirect: {
      replacements: [
        'https://gothub.lunar.icu',
        'https://g.opnxng.com',
        //'https://gothub.projectsegfau.lt', // low availability
        'https://gothub.dev.projectsegfau.lt',
      ],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/gothub'],
    },
  },
  // gist.github.com
  'gist.github.com': {
    redirect: {
      inherit: 'github.com',
      suffix: '/gist/',
      routes: [{ regex: /https?:\/\/(.*?)\//g }], // replace entire domain
    },
    redirectToFarside: {
      inherit: 'github.com',
      suffix: '/gist/',
      routes: [{ regex: /https?:\/\/(.*?)\//g }],
    },
  },

  // imdb.com, m.imdb.com
  'imdb.com': {
    redirect: {
      replacements: [
        'https://libremdb.pussthecat.org',
        'https://libremdb.iket.me',
        'https://ld.vern.cc',
        'https://libremdb.lunar.icu',
      ],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/libremdb'],
    },
  },
  'm.imdb.com': { inherit: 'imdb.com' },

  // genius.com
  // Low feature parity
  'genius.com': {
    redirect: {
      replacements: ['https://dumb.privacydev.net', 'https://dm.vern.cc', 'https://dumb.lunar.icu'],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/dumb'],
    },
  },

  // medium.com - Uncomment to use
  // Low feature parity by design
  // Not a proxy by design, alternative frontend still requests from the official servers
  //
  // Recommend setting Medium to noscript and/or loading through more standard proxies such as TOR
  // Medium with JS disabled works as of now, but other proxy sites such as archive.org can be used if needed
  /* --- Remove this line to use --- //
  'medium.com': {
    redirect: {
      replacements: ['https://scribe.rip', 'https://sc.vern.cc', 'https://m.opnxng.com'],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/scribe'],
    },
  },
  // ------------------------------- */

  // fandom.com - Uncomment to use
  // Not a full proxy, alternative frontend still requests from the official servers
  //
  // Recommend simply using a content blocker to block ads and other annoyances
  /* --- Remove this line to use --- //
  '.fandom.com': {
    allowFuzzy: true,
    redirect: {
      replacements: [
        'https://breezewiki.com',
        'https://antifandom.com',
        'https://breezewiki.pussthecat.org',
        'https://bw.projectsegfau.lt',
        'https://breeze.hostux.net',
        'https://bw.artemislena.eu',
        'https://breeze.nohost.network',
        'https://z.opnxng.com',
      ],
      routes: [
        {
          regex: /^https?:\/\/(www\.)?([a-z\-]+)\..*?\//g,
          suffix: '/$2/', // suffix /{subdomain} on hostname
        },
      ],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/breezewiki'],
      routes: [
        {
          regex: /^https?:\/\/(www\.)?([a-z\-]+)\..*?\//g,
          suffix: '/$2/',
        },
      ],
    },
  },
  // ------------------------------- */

  // wikipedia.org - Uncomment to use
  //
  // Recommend setting Wikipedia to noscript and/or loading through more standard proxies such as TOR
  // If absolutely needed, recommend rolling your own Wikiless instance routed through a proxy or VPN
  // Wikipedia trustworthiness and scriptless tracking is more or less equivalent to wikiless instances
  /* --- Remove this line to use --- //
  'wikipedia.org': {
    redirect: {
      replacements: [
        'https://wiki.adminforge.de',
        'https://wikiless.lunar.icu',
        'https://wikiless.org',
        'https://wl.vern.cc',
      ],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/wikiless'],
    },
  },
  // ------------------------------- */
};

/**
 * Configure site link exclusions
 *
 * The most common exclusion will be on the first-party site itself, as many proxies are not complete replacements.
 * Common unsuppoorted features and paths are excluded, though this is not intended to exhaustively track the list of
 * proxied frontends and availability or configuration of individual instances.
 *
 * excludeLinks can exclude links that match any provided rule, namely matchingPath
 * excludeOn can exclude links when host matches `self` or any provided, e.g. by matchingPath (all paths if empty)
 * @typedef {{
 *  excludeLinks?: {
 *    matchingPath?: (string|RegExp)[],
 *    matchingHost?: (string|RegExp)[],
 *    matchingUrl?: (string|RegExp)[],
 *    matchingText?: (string|RegExp)[],
 *    inherit?: string // inherit any undefined properties in `excludeLinks`
 *  },
 *  excludeOn?: {
 *    matchingPath?: (string|RegExp)[],
 *    matchingHost?: (string|RegExp)[],
 *    matchingUrl?: (string|RegExp)[],
 *    matchingBody?: (string|RegExp)[],
 *    matchingHead?: (string|RegExp)[],
 *    inherit?: string // inherit any undefined properties in `excludeOn`
 *  },
 *  allowFuzzy?: boolean, // allows fuzzy host matching, for subdomains
 *  inherit?: string // inherits any undefined exclusion rules
 * }} Exclusion
 * @typedef {Object<string, Exclusion>} ExclusionList
 */

/**
 * Configure per-site exclusions
 * @type {ExclusionList}
 */
const EXCLUSIONS = {
  // youtube.com, m.youtube.com, youtube-nocookie.com
  'youtube.com': {
    excludeLinks: {
      matchingPath: ['/users/'],
    },
    excludeOn: {
      // exclude linking out from the official site
      matchingHost: ['youtube.com', 'youtube-nocookie.com'],
      matchingHead: [
        '<meta property="og:title" content="Piped">',
        /<meta property="og:site_name" content=".*Invidious">/,
      ],
    },
  },
  'm.youtube.com': { inherit: 'youtube.com' },
  'youtube-nocookie.com': { inherit: 'youtube.com' },

  // reddit.com
  'reddit.com': {
    excludeLinks: {
      // old.reddit.com can be used almost entirely with noscript
      matchingHost: ['old.reddit.com'],
    },
    excludeOn: {
      // exclude on reddit.com but still proxy links while browsing old.reddit.com
      matchingUrl: [/^https?:\/\/(www\.)?(?!old\.)reddit.com/],
    },
  },

  // stackoverflow.com, {subdomain}.stackexchange.com
  'stackoverflow.com': {
    excludeLinks: {
      matchingPath: ['/questions/tagged/', '/users/'],
    },
    excludeOn: {
      matchingHost: ['stackoverflow.com', 'stackexchange.com'],
    },
  },
  '.stackexchange.com': {
    allowFuzzy: true,
    inherit: 'stackoverflow.com',
  },

  // quora.com
  'quora.com': { excludeOn: { matchingHost: ['quora.com'] } },

  // {artist}.bandcamp.com
  '.bandcamp.com': { allowFuzzy: true, excludeOn: { matchingHost: ['bandcamp.com'] } },

  // instagram.com
  'instagram.com': {
    excludeOn: {
      matchingHost: ['instagram.com'],
      matchingHead: [
        /<meta property="og:title" content="[a-zA-Z0-9 _\-+=.,:;'?\/\\`!@#$%^&*()-_\[\]{}|]+? • Proxigram">/,
      ],
    },
  },

  // tiktok.com
  'tiktok.com': {
    excludeOn: {
      matchingHost: ['tiktok.com'],
      // exclude on ProxiTok itself, linking out to original
      matchingHead: ['<meta property="og:site_name" content="ProxiTok">'],
    },
  },

  // imgur.com
  'imgur.com': { excludeOn: { matchingHost: ['imgur.com'] } },
  'i.imgur.com': { inherit: 'imgur.com' },
  'i.stack.imgur.com': { inherit: 'imgur.com' },

  // github.com
  // a lot to blacklist, can also whitelist limited functionality instead
  'github.com': {
    excludeLinks: {
      matchingText: [],
      // prettier-ignore
      matchingPath: ['/actions','/blame/','/codespaces/','/collections/','/commit/','/commits/','/compare/','/customer-stories','/delete/','/discussions/','/enterprise/','/events/','/features/','/graphs/','/issues','/marketplace/','/notifications/','/orgs/','/projects/','/pulls', '/pull/','/pulse','/releases','/security','/sessions/','/sponsors/','/tags','/tree/','/wiki/'],
    },
    excludeOn: {
      matchingHost: ['github.com'],
      // exclude on Gothub page itself, linking out
      matchingBody: ['<a href="https://codeberg.org/gothub/gothub">Source code</a>'],
    },
  },
  'gist.github.com': {
    excludeLinks: {
      // gist single directory paths, e.g. users, /discover, /starred
      matchingPath: [/^\/[A-Za-z0-9_.-]+\/?$/],
    },
    inherit: 'github.com',
  },

  // imdb.com
  'imdb.com': {
    excludeOn: {
      matchingHost: ['imdb.com'],
      // exclude on libremdb itself, linking out
      matchingHead: ['<meta property="og:site_name" content="libremdb">'],
    },
  },

  // genius.com
  // /artist and other pages may not work, but not blacklisting any paths for now
  'genius.com': { excludeOn: { matchingHost: ['genius.com'] } },

  // medium.com
  'medium.com': { excludeOn: { matchingHost: ['medium.com'] } },

  // fandom.com
  'fandom.com': { excludeOn: { matchingHost: ['fandom.com'] } },

  // wikipedia.org
  'wikipedia.org': { excludeOn: { matchingHost: ['wikipedia.org'] } },
};

/**
 * Begin script logic
 *
 * Do not modify below if adding or making changes to available proxies
 */
const DEBUG = false; // Console logging enabled when true

// Tag names of hovered link elements that can be proxified
const PROXIFY_ON = ['A', 'IFRAME'];
// Tag names of hovered elements that should also lookup to the parent node for links
// TODO: per-tag and per-site configuration
const PARENT_LOOKUP_ON = [
  'BUTTON',
  'IMG',
  'SVG',
  'H1',
  'H2',
  'H3',
  'H4',
  'H5',
  'H6',
  'B',
  'I',
  'EM',
  'STRONG',
  'SMALL',
  'SUP',
  'SUB',
  'S',
  'U',
  'LI',
];
// Default depth limit to ancestor lookup recursion
const PARENT_LOOKUP_STEPS = 1;
/**
 * Additional per-site parent lookup config, to fix site-specific proxification
 * TODO: further segment sites on matchesPath or other rules and per-nodeName configs
 * @type { Object<string, {nodeNames: string[], steps: number} > }
 */
const PARENT_LOOKUP_ON_SITE = {
  // google.com mobile search results div
  // TODO: improve mobile search inline media touch events swallowed
  'google.com': { nodeNames: ['DIV'], steps: 4 },
  // duckduckgo.com search results span, hero summary SVGs and title container div
  'duckduckgo.com': { nodeNames: ['SPAN', 'DIV', 'PATH', 'RECT'], steps: 4 },
  // startpage.com search results inline images/videos
  'startpage.com': { steps: 2 },
};

// Tag names of hovered elements that should also globally search all hovered elements for links
const HOVER_LOOKUP_ON = ['P', 'SPAN', 'LI', 'LABEL', /*'DIV',*/ 'BUTTON', 'IMG', 'SVG'];
// Additional tag names to search per-site, to fix site-specific proxificaton
const HOVER_LOOKUP_ON_HOST = {
  'duckduckgo.com': ['DIV'],
};

const CLASS_PROXIFIED = 'proxi-fied';
const CLASS_PROXIFIED_LIVE = 'proxi-live';
const CLASS_BODY_FARSIDE = 'proxi-side';
const CLASS_BODY_BONAFIDE = 'proxi-fide';
const CLASS_BODY_BITE = 'proxi-bite';
const CLASS_BODY_FRAMEBITE = 'proxi-framebite';
const STYLE_HIGHLIGHT_ID = 'proxi-highlite';
const ATTR_PROXIFIED_SITE = 'proxi-site';
const ATTR_PROXIFIED_FARSIDE = 'proxi-site-side';
const ATTR_PROXIFIED_DENIED = 'proxi-nied';
const ATTR_BONAFIDE_SITE = 'proxi-site-bonafide';
const ATTR_IS_FARSIDE = 'proxi-side';

// https://uibakery.io/regex-library/url
const REGEX_URL =
  /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/;

// Key event modifiers for selecting destination
document.body.addEventListener('keydown', e => handleModifierKey(e));
document.body.addEventListener('keyup', e => handleModifierKey(e));
document.body.addEventListener('touchstart', e => handleModifierGesture(e));

/**
 * Lazy load link processing when user interacts per-element
 *
 * Handle hovering over links and completed keyboard navigation over link
 * Does not handle other basic redirects such as form action or onclick attributes
 */
document.body.addEventListener('mouseover', handleElement); // link hover
document.body.addEventListener('touchstart', handleElement); // link touch
document.body.addEventListener('keyup', handleElement); // keyboard navigation

/**
 * Hover event handler to find an anchor hyperlink to check
 * @param {Event} e
 */
function handleElement(e) {
  // Currently support specific events, and optional key nav filter
  if (
    e.type !== 'mouseover' &&
    e.type !== 'touchstart' &&
    (e.type !== 'keyup' || (!!KEY_NAVIGATE && e.key !== KEY_NAVIGATE))
  ) {
    return;
  }

  // Ignore target on multi-touch touch event
  if (e.type === 'touchstart' && e.touches.length !== 1) {
    return;
  }

  // Look for any element or elements that this event could be trying to proxify
  const proxifiableEls = getProxifiableElements(e);
  if (!proxifiableEls || !proxifiableEls.length) return;

  // Prioritize anchor links
  // Take the first anchor, even if it has already been processed
  const targetAnchor = proxifiableEls.find(el => el.nodeName === 'A');
  if (!!targetAnchor) {
    // Hovered anchor found, process it unless it was already processed before
    if (!isElementProxified(targetAnchor)) {
      handleAnchorEl(targetAnchor);
    } else {
      // Otherwise, trigger an update on the link element to ensure it is current
      setTimeout(() => {
        updateProxifiedElement(targetAnchor, 'href');
      }, 0);
    }

    // Only handle one element per event for now to avoid overeager proxification
    return;
  }

  // Handle iframe, if enabled
  // If both or either bite key is empty, handle on any interaction
  if (ENABLE_IFRAME_PROXIFIED) {
    const isProxibite = document.body.classList.contains(CLASS_BODY_BITE);
    const isFramebite = document.body.classList.contains(CLASS_BODY_FRAMEBITE);
    const canBite =
      (!KEY_PROXIBITE || isProxibite || e.key === KEY_PROXIBITE || e.touches?.length === TOUCH_PROXIBITE) &&
      (!KEY_FRAMEBITE || isFramebite || e.key === KEY_FRAMEBITE || e.touches?.length === TOUCH_FRAMEBITE);
    const targetIframe = canBite && proxifiableEls.find(el => el.nodeName === 'IFRAME');
    if (!!targetIframe) {
      if (!isElementProxified(targetIframe)) {
        handleIframe(targetIframe);
      } else {
        setTimeout(() => {
          updateProxifiedElement(targetIframe, 'src');
        }, 0);
      }
    }

    return;
  }
}

/**
 * Handle an anchor element to be proxified
 * Allows re-proxifying the anchor
 *
 * @param {HTMLAnchorElement} el Anchor element
 * @returns {void}
 */
function handleAnchorEl(el) {
  // Perform element and url validation and returns a valid proxy
  const { url, proxy } = preproxifyElement(el, 'href') || {};

  // Perform any global link modifications
  if (ENABLE_REFERER_HIDE_PAGEWIDE) el.rel = 'noreferrer';
  let optSmite;
  if (
    ENABLE_ATTRIBUTES_SMITE &&
    !!el.href &&
    !!(optSmite = getByHost(ENABLE_ATTRIBUTES_SMITE_ON_SITE, window.location.host, true))
  ) {
    // If enabled on the current site, strip any click event attributes from this anchor element
    // Resulting hyperlink is intended to be a primitive link with no events intercepting navigation
    // TODO: Support reverting events on bonafide
    for (const attr of optSmite.attributes) {
      el.removeAttribute(attr);
    }
  }

  // Proxify the link
  // Error handling will have been done on preproxification
  if (!!proxy) {
    proxifyElement(
      el,
      url,
      'href',
      proxy,
      /** @param {HTMLAnchorElement} el */ el => {
        // Trigger an update on the link now that it has been proxified
        updateProxifiedElement(el, 'href');

        // Upgrade undefined or noopener relationship to norefererer
        // If `ENABLE_REFERER_HIDE`, override on all proxified links
        // TODO: Support modifier key to revert change?
        if (!el.rel || el.rel === 'noopener' || ENABLE_REFERER_HIDE) el.rel = 'noreferrer';
      }
    );
  }
}

/**
 * Handle an iframe to be proxified
 * Allows re-proxifying the frame
 *
 * @param {HTMLIFrameElement} el
 * @returns {void}
 */
function handleIframe(el) {
  const { url, proxy } = preproxifyElement(el, 'src') || {};
  if (!!proxy)
    proxifyElement(
      el,
      url,
      'src',
      proxy,
      /** @param {HTMLIframeElement} el */ el => {
        // Trigger an update on the link now that it has been proxified
        updateProxifiedElement(el, 'src');
      }
    );
}

/**
 * Get the element or elements that can be proxified, including those already proxified,
 * from an event where the user is hovering or making a selection
 *
 * @param {MouseEvent | KeyboardEvent | TouchEvent} e
 * @returns {Element[]?} If none, null
 */
function getProxifiableElements(e) {
  if (!(e instanceof MouseEvent || e instanceof KeyboardEvent || e instanceof TouchEvent)) {
    error('Invalid event');
    return null;
  }

  if (!e.target) return null;
  const nodeName = e.target.nodeName.toUpperCase();

  // Anchor links get top priority if directly targeted
  if (nodeName === 'A') return [e.target];

  // Otherwise, build a list of candidates in the order of probable priority
  // Add in other directly targeted non-anchor links to the top
  const proxifiableEls = [];
  if (PROXIFY_ON.includes(nodeName)) proxifiableEls.push(e.target);

  // Parents may not receive propagated events, so use this event now to check if nested under a link
  let optLookup = getByHost(PARENT_LOOKUP_ON_SITE, window.location.host, true);
  if (PARENT_LOOKUP_ON.includes(nodeName) || optLookup?.nodeNames?.includes(nodeName)) {
    // Step up through ancestors
    // Or use target.closest() to search all ancestors
    const lookupSteps = Math.max(optLookup?.steps ? optLookup.steps : 1, PARENT_LOOKUP_STEPS);
    let currentNode = e.target;
    for (let step = 0; step < lookupSteps; step++) {
      if (!!currentNode.parentNode) {
        currentNode = currentNode.parentNode;

        // take processed or unprocessed proxifiable elements
        if (PROXIFY_ON.includes(currentNode.nodeName.toUpperCase())) {
          proxifiableEls.push(currentNode);
        }
      } else {
        break;
      }
    }
  }

  // A link may be a sibling or other relative, or not even a relative, that does not receive propagated
  // For mouse hover or bite event, find any link element that is currently being hovered
  // TODO: Skip previously lookup elements and handle updateProxifiedElement rerender separately from proxifying

  const isHoverLookup =
    e instanceof MouseEvent &&
    (HOVER_LOOKUP_ON.includes(nodeName) || HOVER_LOOKUP_ON_HOST[window.location.host]?.includes(nodeName));
  const isProxiBite = !!KEY_PROXIBITE && document.body.classList.contains(CLASS_BODY_BITE);
  if (isHoverLookup || isProxiBite) {
    // Select all hovered elements and take the processed or unprocessed proxifiable elements
    const hoveredEls = document.querySelectorAll(':hover');
    for (const el of hoveredEls) {
      if (PROXIFY_ON.includes(el.nodeName.toUpperCase())) {
        proxifiableEls.push(el);
      }
    }
  }

  return !!proxifiableEls.length ? proxifiableEls : null;
}

/**
 * Perform element and destination url validation and return a valid proxy
 *
 * @param {HTMLElement} el Target element to be validated for proxification
 * @param {string} destinationAttr Element link destination attribute name
 * @returns {{url, Proxy} | null} Returns a random proxy for the element, null if none
 */
function preproxifyElement(el, destinationAttr) {
  const destination = el[destinationAttr];
  const text = el.outerText;

  // Validate anchor is intended to be a hyperlink
  if (!destination.length) {
    el.setAttribute(ATTR_PROXIFIED_DENIED, 'Invalid, empty, or undefined destination attribute');
    return null;
  }
  if (!REGEX_URL.test(destination)) {
    el.setAttribute(ATTR_PROXIFIED_DENIED, 'Hyperlink destination is not a valid absolute URL');
    return null;
  }

  // Parse the destination as a URL
  let url;
  try {
    url = new URL(destination);
  } catch (ex) {
    // Exit on malformed URL
    error(`[${text}](${destination}) error parsing URL: ${ex}`);
    el.setAttribute(ATTR_PROXIFIED_DENIED, 'Error parsing URL');
    return null;
  }

  // Exit if the original link is excluded for any reason
  // Page-wide exclusions to each destination host are memoized so may save unnecessary checking
  let excludedReason;
  if ((excludedReason = getExclusionReasonForLink(url, text))) {
    el.setAttribute(ATTR_PROXIFIED_DENIED, excludedReason);
    return null;
  }

  // Get a flattened proxy rule that can be applied for this link
  let proxy = getProxyForLink(url);

  // If no proxy can be found for the link url, optionally search its query string
  // Find a proxy match for any query param that is whitelisted, if used, and is a valid url
  if (!proxy && ENABLE_QUERY_PROXIFIED) {
    for (const [paramKey, paramVal] of url.searchParams) {
      // if query param whitelist is empty, allow all
      // TODO: blacklist or other prevention on undesirable effects like callback urls
      if (!ENABLE_QUERY_PROXIFIED_ON?.length || ENABLE_QUERY_PROXIFIED_ON.includes(paramKey)) {
        let queryStringLinkCandidate = paramVal;
        let queryStringURLCandidate, queryStringProxyCandidate;
        try {
          queryStringLinkCandidate = decodeURI(paramVal); // attempt to decode a full URI encoded to the query string
        } catch (ex) {
          warn(`Failed to decode query string param: ${ex}`);
          continue;
        }

        // skip param if it doesn't look like an absolute url to proxify
        if (!REGEX_URL.test(queryStringLinkCandidate)) {
          continue;
        }

        try {
          queryStringURLCandidate = new URL(queryStringLinkCandidate);
        } catch (ex) {
          error(`Failed to parse validated url for query param "${paramKey}"`);
          continue;
        }

        if (getExclusionReasonForLink(queryStringURLCandidate, text)) {
          continue;
        }

        queryStringProxyCandidate = getProxyForLink(queryStringURLCandidate);

        // if successful, break on query string and take first match
        if (!!queryStringProxyCandidate) {
          url = queryStringURLCandidate;
          proxy = queryStringProxyCandidate;
          break;
        }
      }
    }
  }

  // Automatic exclusion if no proxy found on this pass
  if (!proxy) {
    el.setAttribute(ATTR_PROXIFIED_DENIED, 'Not found in proxies list');
    return null;
  }

  return { url, proxy };
}

/**
 * Proxify the link element with the specified proxy settings
 * Supports anchor and iframe elements for now
 *
 * @param {HTMLAnchorElement | HTMLIFrameElement} el
 * @param {URL} url
 * @param {string} destinationAttr Element link destination attribute name
 * @param {Proxy} proxy
 * @param {Function<HTMLAnchorElement | HTMLIFrameElement>?} onProxification Optional success callback
 * @returns {void}
 */
function proxifyElement(el, url, destinationAttr, proxy, onProxification) {
  if (!(el instanceof HTMLAnchorElement) && !(el instanceof HTMLIFrameElement)) {
    error(`${el} is not a supported element type`);
    return;
  }

  if (!isProxyValid(proxy)) {
    error(`Invalid proxy settings for proxifying ${url}`);
    return;
  }

  let proxifyDenied;

  // Build a url to a random instance
  if (!!proxy.redirect) {
    const instanceList = proxy.redirect.replacements;
    const instanceSuffix = proxy.redirect.suffix;
    const instanceRoutes = proxy.redirect.routes;
    const urlInstanceProxified = getProxifiedUrl(url, instanceList, instanceSuffix, instanceRoutes);

    if (urlInstanceProxified instanceof URL) {
      el.setAttribute(ATTR_PROXIFIED_SITE, urlInstanceProxified);
    } else if (typeof urlInstanceProxified === 'string') {
      proxifyDenied ||= urlInstanceProxified;
    }
  }

  // Build a url to a random Farside service redirect
  if (!!proxy.redirectToFarside) {
    const farsideList = proxy.redirectToFarside.replacements;
    const farsideSuffix = proxy.redirectToFarside.suffix;
    const farsideRoutes = proxy.redirectToFarside.routes;
    const urlFarsideProxified = getProxifiedUrl(url, farsideList, farsideSuffix, farsideRoutes);

    if (urlFarsideProxified instanceof URL) {
      el.setAttribute(ATTR_PROXIFIED_FARSIDE, urlFarsideProxified);
    } else if (typeof urlFarsideProxified === 'string') {
      proxifyDenied ||= urlFarsideProxified;
    }
  }

  // Complete the proxification
  if (el.hasAttribute(ATTR_PROXIFIED_SITE) || el.hasAttribute(ATTR_PROXIFIED_FARSIDE)) {
    // Mark the link as successfully proxified
    el.classList.add(CLASS_PROXIFIED);

    // Store the original anchor href
    if (!el.hasAttribute(ATTR_BONAFIDE_SITE)) {
      el.setAttribute(ATTR_BONAFIDE_SITE, el[destinationAttr]);
    }

    // Success callback
    if (!!onProxification) {
      onProxification(el);
    }
  } else if (!!proxifyDenied) {
    // No effect proxifying, but a denial reason was given
    el.setAttribute(ATTR_PROXIFIED_DENIED, proxifyDenied);
  } else {
    // Log unexpected failure past validated inputs
    error(`Failure proxifying ${url}`);
  }
}

/**
 * Return a proxified URL given a list of replacement hosts and optional routes
 *
 * @param {URL} url
 * @param {string[]} replacements
 * @param {string?} suffix Optional static suffix on the replacement host
 * @param {ProxyRoute[]?} routes Optional route list specifying a regex match with its corresponding options
 * @returns {URL | string | null} Proxified URL, string reason if denied, null if failed
 */
function getProxifiedUrl(url, replacements, suffix, routes) {
  if (!url) {
    error('URL null or undefined');
    return null;
  }

  if (!replacements) {
    error('Replacements list null or undefined');
    return null;
  }

  // Get a cleaned list of replacement strings
  const replacementList = replacements.filter(
    replacement => typeof replacement === 'string' && REGEX_URL.test(replacement)
  );

  // Get a random replacement string from the list and replace
  if (replacementList.length > 0) {
    const replacementRandom = replacementList[Math.floor(Math.random() * replacementList.length)];
    const replacementSuffix = suffix || '';
    const replacementRoute = routes?.find(route => route?.regex instanceof RegExp && route.regex.test(url.href));

    const failedWhitelist = !!routes && !replacementRoute; // proxy routes whitelist defined but none matched
    let urlProxified = new URL(url);
    if (!!replacementRoute) {
      // Regex replacement
      // TODO: Support replacement function as an alternative to the replacement string route suffix
      try {
        const routeRegex = replacementRoute.regex;
        const routeSuffix = replacementRoute.suffix || '';
        const hrefProxified = url.href.replace(routeRegex, replacementRandom + replacementSuffix + routeSuffix);
        urlProxified = new URL(hrefProxified);
      } catch (ex) {
        warn(`Invalid regex replaced URL for ${url}`);
        return null;
      }
    } else if (!failedWhitelist) {
      // Default regex replacement on url host and scheme
      // Skip if whitelisted routes were defined but not matched
      try {
        const hrefProxified = url.href.replace(
          /(https?:\/\/)(.*?)(\/.*)/,
          replacementRandom + replacementSuffix + '$3'
        );
        urlProxified = new URL(hrefProxified);
      } catch (ex) {
        error(`Invalid default replaced URL for ${url}`);
        return null;
      }
    }

    if (url.href !== urlProxified.href) {
      return urlProxified;
    } else if (failedWhitelist) {
      return 'Failed routes whitelist';
    } else {
      error(`No effect proxifying ${url}`);
      return null;
    }
  }

  warn(`Missing or invalid replacement URLs for ${url}`);
  return null;
}

/**
 * Returns matching proxy for the specified link url
 *
 * @param {URL} url
 * @returns {Proxy?} Proxy settings by url, null if not found
 */
function getProxyForLink(url) {
  // Get the redirect rules from the proxy matching url host
  /** @type {Proxy?} */
  const proxy = getByHost(PROXIES, url.host, true);

  // Inherit any redirect rules that were left completely undefined, then inherit
  // any rule properties that were left undefined
  /** @type {Proxy?} */
  let flattenedProxyRule;
  try {
    flattenedProxyRule = flattenInheritance(proxy, PROXIES);
  } catch (ex) {
    error(`Error inheriting proxy rules for ${url}: ${ex}`);
    return false;
  }

  if (!!flattenedProxyRule) {
    return flattenedProxyRule;
  }

  return null;
}

/**
 * Returns whether link URL is supported by a listed proxy and not explicitly excluded
 *
 * @param {URL} url
 * @param {string} text
 * @returns {string|null} Reason if link is invalid, null if valid
 */
function getExclusionReasonForLink(url, text) {
  // Exit if this URL is excluded by path or innertext, or by the current page location
  // Look for exclusion rules on both this host and inherited, if applicable
  /** @type {Exclusion?} */
  const exclusion = getByHost(EXCLUSIONS, url.host, true);

  /** @type {Exclusion?} */
  let flattenedExclusionRule;
  try {
    flattenedExclusionRule = flattenInheritance(exclusion, EXCLUSIONS);
  } catch (ex) {
    // Likely inheriting rule that is unexpectedly undefined, probably due to incorrect or nested inheritance
    warn(`Error inheriting exclusion rules for ${url}: ${ex}`);
    return 'Error inheriting exclusion rule';
  }

  // With the final exclusion rule set, check if the link url is excluded
  if (!!flattenedExclusionRule) {
    const excludedReason = isLinkExcludedByRule(url, text, flattenedExclusionRule);
    if (!!excludedReason) {
      return excludedReason;
    }
  }

  return null;
}

/**
 * Flatten generic object upwards with inherited data
 *
 * Only goes one level deep, both for inheritance and nested inheritors
 * @typedef {{
 *  [keys: string]: Inheritor,
 *  inherit?: string
 * }} Inheritor
 *
 * @param {Inheritor} source
 * @param {Object<string, Inheritor>} dictionary
 * @returns {Inheritor?}
 * @throws {Error} Exception on copying with Object.assign()
 */
function flattenInheritance(source, dictionary) {
  // Avoid destructive shallow copies on `source` or other objects
  let root = source;
  if (!root || !(typeof root === 'object')) return null;

  const rootInheritance = dictionary[root.inherit];
  if (!!rootInheritance && typeof rootInheritance === 'object') {
    // Merge two rules into one
    root = Object.assign({}, rootInheritance, root);
  }

  // For the final state of inherited nested objects that also inherit,
  // flatten their inheritance as well (non-recursive)
  for (const key of Object.keys(root)) {
    const branch = root[key];
    if (branch?.inherit && dictionary[branch.inherit]) {
      // if inherited object also includes corresponding nested data, merge to
      const branchInheritance = dictionary[branch.inherit]?.[key];
      if (!!branchInheritance && typeof branchInheritance === 'object') {
        root[key] = Object.assign({}, branchInheritance, branch);
      }
    }
  }

  // Warn if it appears that recursive inheritance is configured
  // Inheritance beyond the first level of root and property data is not supported
  if (!!rootInheritance?.inherit) {
    warn(`Multi depth recursion hit ${rootInheritance.inherit} but is not supported`);
  }

  return root;
}

/**
 * Validate proxy rules have enough instance or Farside rules to proxify a link
 *
 * @param {Proxy} proxy
 * @returns {boolean} true if valid, false otherwise
 */
function isProxyValid(proxy) {
  if (!proxy) return false;

  const isInstanceRedirectValid =
    !!proxy.redirect && proxy.redirect.replacements && proxy.redirect.replacements.length > 0;
  const isFarsideRedirectValid =
    !!proxy.redirectToFarside &&
    proxy.redirectToFarside.replacements &&
    proxy.redirectToFarside.replacements.length > 0;
  if (!isInstanceRedirectValid && !isFarsideRedirectValid) {
    // neither instance or Farside rules fully defined
    return false;
  }

  return true;
}

/**
 * Handle keydown and keyup event to set modifiers on proxified links
 * @param {KeyboardEvent} e
 * @returns {void}
 */
function handleModifierKey(e) {
  if (e.type !== 'keyup' && e.type !== 'keydown') {
    error('Invalid modifier key event');
    return;
  }

  let isDomChanged = false;
  toggleModifierKeyState(e, KEY_MODIFIED, CLASS_BODY_FARSIDE) && (isDomChanged = true);
  toggleModifierKeyState(e, KEY_BONAFIDE, CLASS_BODY_BONAFIDE) && (isDomChanged = true);
  toggleModifierKeyState(e, KEY_PROXIBITE, CLASS_BODY_BITE) && (isDomChanged = true);
  toggleModifierKeyState(e, KEY_FRAMEBITE, CLASS_BODY_FRAMEBITE) && (isDomChanged = true);

  // If DOM state changed, trigger an render update on proxified elements
  if (isDomChanged) {
    setTimeout(() => {
      updateAllProxifiedAnchors(true);
    }, 0);
  }
}

/**
 * Handle multi-touch gesture event to set modifiers on proxified links, for touch-only devices
 * @param {TouchEvent} e
 * @returns {void}
 */
function handleModifierGesture(e) {
  if (!(e instanceof TouchEvent)) {
    error('Invalid modifier touch gesture event');
    return;
  }

  // only support multi-touch to trigger/reset modifiers
  if (e.touches.length <= 1) {
    return;
  }

  let isDomChanged = false;
  toggleModifierGestureState(e, TOUCH_MODIFIED, CLASS_BODY_FARSIDE) && (isDomChanged = true);
  toggleModifierGestureState(e, TOUCH_BONAFIDE, CLASS_BODY_BONAFIDE) && (isDomChanged = true);
  toggleModifierGestureState(e, TOUCH_PROXIBITE, CLASS_BODY_BITE, true) && (isDomChanged = true);
  toggleModifierGestureState(e, TOUCH_FRAMEBITE, CLASS_BODY_FRAMEBITE, true) && (isDomChanged = true);

  // If DOM state changed, trigger an render update on proxified elements
  if (isDomChanged) {
    setTimeout(() => {
      updateAllProxifiedAnchors(true);
    }, 0);
  }
}

/**
 * Toggle a page-wide class by checking KeyboardEvent matches the specified toggleKey
 *
 * @param {KeyboardEvent} e
 * @param {string} toggleKey
 * @param {string} toggleClass Classname to toggle in DOM
 * @returns {boolean} True if changed, false if no change to DOM
 */
function toggleModifierKeyState(e, toggleKey, toggleClass) {
  if (!(e instanceof KeyboardEvent)) {
    error('Event is not KeyboardEvent');
    return;
  }

  if (e.type !== 'keyup' && e.type !== 'keydown') {
    error('Invalid modifier key event');
    return false;
  }

  if (e.key === toggleKey) {
    const setModifierOn = e.type === 'keydown';
    const originalState = document.body.classList.contains(toggleClass);

    if (setModifierOn) {
      if (!originalState) {
        document.body.classList.add(toggleClass);
        return true;
      }
    } else {
      if (originalState) {
        document.body.classList.remove(toggleClass);
        return true;
      }
    }
  }

  return false;
}

/**
 * Toggle a page-wide class by checking TouchEvent matches the specified touch gesture
 *
 * @param {ToggleEvent} e
 * @param {number} toggleGesture Simple gesture based on number of fingers
 * @param {string} toggleClass Classname to toggle in DOM
 * @param {boolean} isManualOff Optional setting to require manually repeating gesture to toggle off
 * @returns {boolean} True if changed, false if no change to DOM
 */
function toggleModifierGestureState(e, toggleGesture, toggleClass, isManualOff = false) {
  if (!(e instanceof TouchEvent)) {
    error('Event is not TouchEvent');
    return;
  }

  // detect gesture
  // only simple touch count gesture is supported currently
  const gestureTouchCount = toggleGesture;
  const isGestured = gestureTouchCount !== TOUCH_PROXIFIED && e.touches.length === gestureTouchCount;

  // handle gesture to toggle modifier
  const originalState = document.body.classList.contains(toggleClass);
  if (isGestured) {
    if (!originalState) {
      document.body.classList.add(toggleClass);
      return true;
    } else if (isManualOff) {
      // gesture off, if supported by this gesture
      document.body.classList.remove(toggleClass);
      return true;
    }
  } else if (!isManualOff) {
    // automatic off, if supported by this gesture
    if (originalState) {
      document.body.classList.remove(toggleClass);
      return true;
    }
  }

  return false;
}

/**
 * Rerender proxified changes to the specified link element
 *
 * @param {HTMLElement} el
 * @param {string} destinationAttr Element link destination attribute name
 * @returns {void}
 */
function updateProxifiedElement(el, destinationAttr) {
  if (!(el instanceof HTMLElement)) {
    error(`${el} is not an HTMLElement`);
    return;
  }

  if (el.hasAttribute(ATTR_PROXIFIED_DENIED)) {
    return; // element has been proxified but denied
  }

  if (!el[destinationAttr]) {
    error(`Invalid attribute ${destinationAttr}`);
    return;
  }

  if (!isElementProxified(el)) {
    error(`Anchor [${destinationAttr}=${el[destinationAttr]}] is not proxified`);
    return;
  }

  // Check DOM modifier state
  // Instead of using internal state, follow what the rendered DOM has
  const isBonafide = document.body.classList.contains(CLASS_BODY_BONAFIDE);
  // attempt to use Farside automatically if direct site is not found
  const isFarside = document.body.classList.contains(CLASS_BODY_FARSIDE) || !el.hasAttribute(ATTR_PROXIFIED_SITE);

  // Reset active states before re-applying as necessary
  el.removeAttribute(ATTR_IS_FARSIDE);
  el.classList.remove(CLASS_PROXIFIED_LIVE);

  // Update link destination
  // The original, bonafide link takes precedence
  if (isBonafide && el.hasAttribute(ATTR_BONAFIDE_SITE)) {
    if (el[destinationAttr] !== el.getAttribute(ATTR_BONAFIDE_SITE))
      el[destinationAttr] = el.getAttribute(ATTR_BONAFIDE_SITE);
  } else if (isFarside && el.hasAttribute(ATTR_PROXIFIED_FARSIDE)) {
    if (el[destinationAttr] !== el.getAttribute(ATTR_PROXIFIED_FARSIDE))
      el[destinationAttr] = el.getAttribute(ATTR_PROXIFIED_FARSIDE);
    el.setAttribute(ATTR_IS_FARSIDE, true);
    el.classList.add(CLASS_PROXIFIED_LIVE);
  } else if (el.hasAttribute(ATTR_PROXIFIED_SITE)) {
    if (el[destinationAttr] !== el.getAttribute(ATTR_PROXIFIED_SITE))
      el[destinationAttr] = el.getAttribute(ATTR_PROXIFIED_SITE);
    el.classList.add(CLASS_PROXIFIED_LIVE);
  }
}

/**
 * Rerender state to all proxified anchor elements in the document
 *
 * @param {boolean} doHoveredOnly Only render proxified elements that are being hovered
 * @returns {void}
 */
function updateAllProxifiedAnchors(doHoveredOnly) {
  const proxifiedEls = document.querySelectorAll(
    `a.${CLASS_PROXIFIED}${doHoveredOnly ? ':is(:hover, :focus-within)' : ''}`
  );
  for (const proxifiedEl of proxifiedEls) {
    updateProxifiedElement(proxifiedEl, 'href');
  }
}

/**
 * Add proxified link styles to indicate functionality and readiness
 *
 * Highlight proxied links yellow
 * Highlight Farside-redirected links green
 */
// TODO: Manually apply inline element styles to avoid style-src CSP
const isHoverOnly = true; // toggle styling on when hovering on link
const _important = true ? '!important' : ''; // toggle overriding page styles as much as possible
// Style anchor element and children under anchor element
const selectorAnchor = isHoverOnly
  ? `a.${CLASS_PROXIFIED_LIVE}:is(:hover, :focus-within)`
  : `a.${CLASS_PROXIFIED_LIVE}`;
// add additional styles at higher specificity to child containers
const selectorChildren = ':is(p, span, h1, h2, h3, h4, h5, h6, label, div, button)';
addPageStyle(
  `${selectorAnchor} { \
    color: black ${_important}; \
    background-color: yellow ${_important}; \
    text-shadow: none ${_important}; \
    \
    font-style: oblique ${_important}; \
    font-weight: bold ${_important}; \
    \
    ${selectorChildren} { \
      color: yellow ${_important}; \
      background-color: black ${_important}; \
      text-shadow: none ${_important}; \
      \
      font-style: oblique ${_important}; \
      font-weight: bold ${_important}; \
    } \
    :is(img) { \
      filter: sepia(1) hue-rotate(20deg) contrast(1.25) brightness(1.25); ${_important}; \
    }\
  }`
);
// Add optional highlighter padding at lowest specificity :where()
addPageStyle(
  `:where(${selectorAnchor}) { \
    // padding: 0 0.3em; \
    \
    ${selectorChildren} { \
      padding: 0 0.3em; \
    } \
  }`
);
// Change colors when links redirect through Farside
addPageStyle(
  `${selectorAnchor}[${ATTR_IS_FARSIDE}="true"] { \
    color: yellowgreen ${_important}; \
    background-color: black ${_important}; \
    \
    ${selectorChildren} { \
      color: black ${_important}; \
      background-color: yellowgreen ${_important}; \
    } \
    :is(img) { \
      filter: invert(1) sepia(1) hue-rotate(45deg) contrast(1.25) brightness(1.25) ${_important}; \
    } \
  }`
);
// Highlight all iframes when frame bite is enabled
addPageStyle(
  `body.proxi-bite.proxi-framebite { \
    iframe { \
      border: yellow solid 0.3em ${_important}; \
    } \
    iframe[${ATTR_IS_FARSIDE}="true"]  { \
      border-color: yellowgreen ${_important}; \
    } \
  }`
);

/**
 * Insert a page-level style
 * Adds a document stylesheet to write to when needed
 *
 * @param {string} css
 * @returns {void}
 */
function addPageStyle(css) {
  const style =
    document.getElementById(STYLE_HIGHLIGHT_ID) ||
    (function () {
      const style = document.createElement('style');
      style.id = STYLE_HIGHLIGHT_ID;
      document.head.appendChild(style);
      return style;
    })();

  const sheet = style.sheet;
  try {
    sheet.insertRule(css, (sheet.rules || sheet.cssRules || []).length);
  } catch (ex) {
    // Likely stylesheet is null from failing to add to DOM
    // Continue on, even without styling
    warn(`Failed to apply style: ${ex}`);
  }
}

/**
 * Check given link url and text against provided exclusion rule
 *
 * @param {URL} url
 * @param {string} text
 * @param {Exclusion} rule
 * @returns {string?} First found exclusion reason, null if false
 */
function isLinkExcludedByRule(url, text, rule) {
  if (!rule) {
    error(`Invalid flattened exclusion rule for ${url}`);
    return 'Invalid flatted exclusion rule';
  }

  // Return reason if excluding the current page that this link is on
  // Check first to short circuit link processing on pages proxifying is excluded
  // Results are memoized by page after being processed once, manually invalidate `_excludedOn` if needed
  const currentDirection = `${window.location.href}=>${url.host}`;
  if (!!_excludedOn[currentDirection]) {
    return _excludedOn[currentDirection];
  }
  if (!!rule.excludeOn && _excludedOn[currentDirection] === undefined) {
    /** @type {URL | null} */
    let currentUrl = null;
    try {
      currentUrl = new URL(currentDirection);
    } catch (ex) {
      // unexpected failure on parsing current URL, error and continue
      error(`Error parsing page URL: ${ex}`);
    }

    if (!!rule.excludeOn.matchingPath) {
      for (const m of rule.excludeOn.matchingPath) {
        if (!!currentUrl.pathname.match(m)) {
          _excludedOn[currentDirection] = `excludeOn.matchingPath[${m}]`;
        }
      }
    }

    if (!!rule.excludeOn.matchingHost) {
      for (const m of rule.excludeOn.matchingHost) {
        if (!!currentUrl.host.match(m)) {
          _excludedOn[currentDirection] = `excludeOn.matchingHost[${m}]`;
        }
      }
    }

    if (!!rule.excludeOn.matchingUrl) {
      for (const m of rule.excludeOn.matchingUrl) {
        if (!!currentUrl.href.match(m)) {
          _excludedOn[currentDirection] = `excludeOn.matchingUrl[${m}]`;
        }
      }
    }

    if (!!rule.excludeOn.matchingBody) {
      for (const m of rule.excludeOn.matchingBody) {
        if (document.getElementsByTagName('body')[0].innerHTML.match(m)) {
          _excludedOn[currentDirection] = `excludeOn.matchingBody[${m}]`;
        }
      }
    }

    if (!!rule.excludeOn.matchingHead) {
      // clean before searching
      // if the userscript extension mounts to head, may false positive on the userscript itself
      if (_documentHeadCleaned === undefined) {
        const documentHeadTemp = document.createElement('head');
        documentHeadTemp.innerHTML = document.getElementsByTagName('head')[0]?.innerHTML;
        let s;
        for (const scriptNodes = documentHeadTemp.getElementsByTagName('script'); (s = scriptNodes[0]); ) {
          // cut down scriptNodes queue
          s.parentNode.removeChild(s);
        }
        _documentHeadCleaned = documentHeadTemp.innerHTML;
      }

      for (const m of rule.excludeOn.matchingHead) {
        if (_documentHeadCleaned.match(m)) {
          _excludedOn[currentDirection] = `excludeOn.matchingHead[${m}]`;
        }
      }
    }

    // process this page url once even if no matches found for this page
    // return any non-nullish match found, or continue to find other link exclusions
    if (_excludedOn[currentDirection] === undefined) {
      _excludedOn[currentDirection] = null;
    } else if (!!_excludedOn[currentDirection]) {
      return _excludedOn[currentDirection];
    }
  }

  // Return reason if excluding this link
  if (!!rule.excludeLinks) {
    if (!!rule.excludeLinks.matchingPath) {
      for (const m of rule.excludeLinks.matchingPath) {
        if (!!url.pathname.match(m)) return `excludeLinks.matchingPath[${m}]`;
      }
    }

    if (!!rule.excludeLinks.matchingHost) {
      for (const m of rule.excludeLinks.matchingHost) {
        if (!!url.host.match(m)) return `excludeLinks.matchingHost[${m}]`;
      }
    }

    if (!!rule.excludeLinks.matchingUrl) {
      for (const m of rule.excludeLinks.matchingUrl) {
        if (!!url.href.match(m)) return `excludeLinks.matchingUrl[${m}]`;
      }
    }

    if (!!rule.excludeLinks.matchingText) {
      for (const m of rule.excludeLinks.matchingText) {
        if (!!text.match(m)) return `excludeLinks.matchingText[${m}]`;
      }
    }
  }
}
const _excludedOn = {}; // memoized excludeOn results
let _documentHeadCleaned; // memoized cleaned <head>

/**
 * Check if element has been fully processed already
 * @param {HTMLElement} el
 * @returns {boolean}
 */
function isElementProxified(el) {
  return (
    el?.hasAttribute(ATTR_PROXIFIED_SITE) ||
    el?.hasAttribute(ATTR_PROXIFIED_FARSIDE) ||
    el?.hasAttribute(ATTR_PROXIFIED_DENIED)
  );
}

/**
 * Returns the dictionary entry for the specified host, agnostic to WWW
 * Take explicit host match if found, but optionally try searching for SLD+TLD only (strip subdomains)
 *
 * @param {Object<string, T?>} dictionary
 * @param {string} host
 * @param {boolean} allowFuzzy
 * @returns {T?}
 */
function getByHost(dictionary, host, allowFuzzy) {
  if (typeof dictionary !== 'object') {
    error('Invalid dictionary used to look up host');
    return null;
  }

  // Find the explicit entry for the host key
  const explicit = dictionary[host];
  if (!!explicit) {
    return explicit;
  }

  // Strip WWW and try again
  const agnosticWWW = dictionary[host.replace(/^www\./, '')];
  if (!!agnosticWWW) {
    return agnosticWWW;
  }

  // Non-WWW subdomains in hostname may be causing misses
  // Too many TLDs to handle easily, so allow fuzzy matching on SLD+TLD if enabled
  // Search for dictionary keys matching to the end of host and try the first hit
  if (allowFuzzy) {
    // try any dictionary key that has opted into fuzzy matching
    const candidateHostKey = Object.keys(dictionary)
      .filter(hostKey => !!dictionary[hostKey].allowFuzzy)
      .find(hostKey => {
        // try to fit the key to the end of the matching host
        const SLDTLDRegex = new RegExp(escapeRegExp(hostKey) + '$');
        return SLDTLDRegex.test(host);
      });

    const agnosticSubdomain = dictionary[candidateHostKey];
    if (!!agnosticSubdomain) {
      return agnosticSubdomain;
    }
  }

  return null;
}

/**
 * Escape regex special characters in a string
 * TC39 X-standard
 *
 * @param {string} string
 * @returns {string}
 */
function escapeRegExp(string) {
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/**
 * Print to console as error
 *
 * @param  {...any} data
 */
function error(...data) {
  if (DEBUG) {
    console.error(...data);
  }
}

/**
 * Print to console as warning
 *
 * @param  {...any} data
 */
function warn(...data) {
  if (DEBUG) {
    console.warn(...data);
  }
}