Bobby's Pixiv Utils

7/2/2024, 8:37:14 PM

目前為 2025-03-17 提交的版本,檢視 最新版本

// ==UserScript==
// @name        Bobby's Pixiv Utils
// @namespace   https://github.com/BobbyWibowo
// @match       *://www.pixiv.net/*
// @exclude-match *://www.pixiv.net/setting*
// @exclude-match *://www.pixiv.net/manage*
// @icon        https://www.google.com/s2/favicons?sz=64&domain=pixiv.net
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       unsafeWindow
// @run-at      document-end
// @version     1.4.1
// @author      Bobby Wibowo
// @license     MIT
// @description 7/2/2024, 8:37:14 PM
// @noframes
// ==/UserScript==

/* global document, console, location, setTimeout, unsafeWindow, window, Array, CustomEvent, URL, $, GM_addStyle, GM_getValue, GM_setValue */

(function () {
  'use strict'

  const _logTime = () => {
    return new Date().toLocaleTimeString([], {
        hourCycle: 'h12',
        hour: '2-digit',
        minute: '2-digit',
        second: '2-digit',
        fractionalSecondDigits: 3
      })
      .replaceAll('.', ':')
      .replace(',', '.')
      .toLocaleUpperCase();
  };

  const log = (message, ...args) => {
    const prefix = `[${_logTime()}]: `;
    if (typeof message === 'string') {
      return console.log(prefix + message, ...args);
    } else {
      return console.log(prefix, message, ...args);
    }
  };

  /** CONFIG **/

  const ENV = {
    MODE: GM_getValue('MODE'),

    TEXT_EDIT_BOOKMARK: GM_getValue('TEXT_EDIT_BOOKMARK', '✏️'),
    TEXT_EDIT_BOOKMARK_TOOLTIP: GM_getValue('TEXT_EDIT_BOOKMARK_TOOLTIP', 'Edit bookmark'),

    TEXT_TOGGLE_BOOKMARKED: GM_getValue('TEXT_TOGGLE_BOOKMARKED', '🔖'),
    TEXT_TOGGLE_BOOKMARKED_TOOLTIP: GM_getValue('TEXT_TOGGLE_BOOKMARKED', 'Cycle bookmarked display (Right-Click to cycle back)'),
    TEXT_TOGGLE_BOOKMARKED_SHOW_ALL: GM_getValue('TEXT_TOGGLE_BOOKMARKED', 'Show All'),
    TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED: GM_getValue('TEXT_TOGGLE_BOOKMARKED', 'Show Bookmarked'),
    TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED: GM_getValue('TEXT_TOGGLE_BOOKMARKED', 'Show Not Bookmarked'),

    // The following options have preset values. Scroll further to find them.
    // Specifiying custom values will extend instead of replacing them.
    SELECTORS_IMAGE: GM_getValue('SELECTORS_IMAGE'),
    SELECTORS_IMAGE_TITLE: GM_getValue('SELECTORS_IMAGE_TITLE'),
    SELECTORS_IMAGE_ARTIST_AVATAR: GM_getValue('SELECTORS_IMAGE_ARTIST_AVATAR'),
    SELECTORS_IMAGE_ARTIST_NAME: GM_getValue('SELECTORS_IMAGE_ARTIST_NAME'),
    SELECTORS_IMAGE_CONTROLS: GM_getValue('SELECTORS_IMAGE_CONTROLS'),
    SELECTORS_EXPANDED_VIEW_CONTROLS: GM_getValue('SELECTORS_EXPANDED_VIEW_CONTROLS'),
    SELECTORS_MULTI_VIEW: GM_getValue('SELECTORS_MULTI_VIEW'),
    SELECTORS_MULTI_VIEW_CONTROLS: GM_getValue('SELECTORS_MULTI_VIEW_CONTROLS'),

    DATE_CONVERSION: GM_getValue('DATE_CONVERSION', true),
    DATE_CONVERSION_LOCALES: GM_getValue('DATE_CONVERSION_LOCALES', 'en-GB'),
    DATE_CONVERSION_OPTIONS: GM_getValue('DATE_CONVERSION_OPTIONS', {
      hour12: true,
      year: 'numeric',
      month: 'long',
      day: 'numeric',
      hour: '2-digit',
      minute: '2-digit'
    }),
    // This has a preset value. Specifiying a custom value will extend instead of replacing it.
    SELECTORS_DATE: GM_getValue('SELECTORS_DATE'),

    REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME: GM_getValue('REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME', false),

    // This has a preset value. Specifiying a custom value will extend instead of replacing it.
    SECTIONS_TOGGLE_BOOKMARKED: GM_getValue('SECTIONS_TOGGLE_BOOKMARKED'),

    ENABLE_KEYBINDS: GM_getValue('ENABLE_KEYBINDS', true),

    UTAGS_INTEGRATION: GM_getValue('UTAGS_INTEGRATION', true),
    // Presets "block" and "hide" tags. Specifying custom values will extend instead of replacing them.
    UTAGS_BLOCKED_TAGS: GM_getValue('UTAGS_BLOCKED_TAGS'),
    // Instead of merely hiding them à la Pixiv's built-in tags mute.
    UTAGS_REMOVE_BLOCKED: GM_getValue('UTAGS_REMOVE_BLOCKED', false)
  };

  /* DOCUMENTATION
   * -------------
   * For any section that does not have complete selectors, it's implied that they are already matched using selectors contained in sections that preceded it.
   * NOTE: Figure out selectors that are more update-proof.
   * Class names that are formatted as 5 random letters (e.g., hSoPoc) are known to be dynamically generated by Pixiv's stylesheet framework.
   * Whenever they do any updates, significant or otherwise, will cause them to be regenerated.
   *
   * Home's recommended works grid:
   * Image: .sc-96f10c4f-0 > li
   * Title: [data-ga4-label="title_link"]
   * Artist avatar: [data-ga4-label="user_icon_link"]
   * Artist name: [data-ga4-label="user_name_link"]
   * Controls: .sc-eacaaccb-9
   *
   * Home's latest works grid:
   * Image: li[data-ga4-label="thumbnail"]
   *
   * Discovery page's grid:
   * Title: .gtm-illust-recommend-title
   * Controls: .sc-e33a5c4-2
   *
   * Artist page's grid:
   * Image: .sc-9y4be5-1 > li
   * Controls: .sc-iasfms-4
   *
   * Expanded view's artist works bottom row:
   * Image: .sc-1nhgff6-4 > div
   *
   * Expanded view's related works grid:
   * Artist avatar: .sc-1rx6dmq-1
   * Artist name: .gtm-illust-recommend-user-name
   *
   * Artist page's featured works:
   * Image: .sc-1sxj2bl-5 > li
   * Controls: .sc-xsxgxe-3
   *
   * Bookmarks page's grid:
   * Title: .sc-iasfms-6
   * Artist name: .sc-1rx6dmq-2
   *
   * Tag page's grid:
   * Image: .sc-l7cibp-1 > li
   *
   * Rankings page:
   * Image: .ranking-item
   * Title: .title
   * Artist avatar: ._user-icon
   * Artist name: .user-name
   * Controls: ._layout-thumbnail
   */
  const CONFIG = {
    MODE: 'PROD',

    SELECTORS_IMAGE: '.sc-96f10c4f-0 > li, li[data-ga4-label="thumbnail"], .sc-9y4be5-1 > li, .sc-1nhgff6-4 > div, .sc-1sxj2bl-5 > li, .sc-l7cibp-1 > li, .ranking-item',
    SELECTORS_IMAGE_TITLE: '[data-ga4-label="title_link"], .gtm-illust-recommend-title, .sc-iasfms-6, .title',
    SELECTORS_IMAGE_ARTIST_AVATAR: '[data-ga4-label="user_icon_link"], .sc-1rx6dmq-1, ._user-icon',
    SELECTORS_IMAGE_ARTIST_NAME: '[data-ga4-label="user_name_link"], .gtm-illust-recommend-user-name, .sc-1rx6dmq-2, .user-name',
    SELECTORS_IMAGE_CONTROLS: '.sc-eacaaccb-9, .sc-e33a5c4-2, .sc-iasfms-4, .sc-xsxgxe-3, ._layout-thumbnail',
    SELECTORS_EXPANDED_VIEW_CONTROLS: '.sc-181ts2x-0',
    SELECTORS_MULTI_VIEW: '[data-ga4-label="work_content"]',
    SELECTORS_MULTI_VIEW_CONTROLS: '& > .w-full:last-child > .flex:first-child > .flex-row:first-child',
    SELECTORS_DATE: '.dqHJfP',

    SECTIONS_TOGGLE_BOOKMARKED: [
      // Bookmarks page
      {
        selectorParent: '.sc-jgyytr-0',
        selectorHeader: '.sc-s8zj3z-2',
        selectorImagesContainer: '.sc-s8zj3z-4'
      },
      // Artist page
      {
        selectorParent: '.sc-1xj6el2-3',
        selectorHeader: '.sc-1xj6el2-2',
        selectorImagesContainer: '& > div:last-child'
      },
      // Tag page
      {
        selectorParent: '.sc-jgyytr-0',
        selectorHeader: '.sc-7zddlj-0',
        selectorImagesContainer: '.sc-l7cibp-0'
      }
    ],

    UTAGS_BLOCKED_TAGS: ['block', 'hide']
  };

  // Extend preset values with user-defined custom values if applicable.
  for (const key of Object.keys(ENV)) {
    if (key.startsWith('SELECTORS_')) {
      if (ENV[key]) {
        CONFIG[key] += `, ${ENV[key]}`;
      }
    } else if (Array.isArray(CONFIG[key])) {
      if (ENV[key]) {
        const customValues = Array.isArray(ENV[key]) ? ENV[key] : ENV[key].split(',').map(s => s.trim())
        CONFIG[key].push(...customValues);
      }
    } else if (ENV[key] !== undefined) {
      CONFIG[key] = ENV[key];
    }
  }

  let logDebug = () => {};
  let logKeys = Object.keys(CONFIG);
  if (CONFIG.MODE === 'PROD') {
    // In PROD mode, only print some.
    logKeys = ['DATE_CONVERSION', 'REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME', 'ENABLE_KEYBINDS', 'UTAGS_INTEGRATION'];
  } else {
    logDebug = log;
  }

  for (const key of logKeys) {
    log(`${key} =`, CONFIG[key]);
  }

  /** GLOBAL UTILS **/

  const addPageDateStyle = /*css*/`
  .bookmark-detail-unit .meta {
    display: block;
    font-size: 16px;
    font-weight: bold;
    color: inherit;
    margin-left: 0;
    margin-top: 10px;
  }
  `;

  const convertDate = (element, fixJapanTime = false) => {
    let date;

    const attr = element.getAttribute('datetime');
    if (attr) {
      date = new Date(attr);
    } else {
      // For pages which have the date display hardcoded to Japan time.
      let dateText = element.innerText;
      if (fixJapanTime) {
        dateText += ' UTC+9';
      }
      date = new Date(dateText);
    }

    if (!date) {
      return false;
    }

    const timestamp = String(date.getTime());
    if (element.dataset.oldTimestamp && element.dataset.oldTimestamp === timestamp) {
      return false;
    }

    element.dataset.oldTimestamp = timestamp;
    element.innerText = date.toLocaleString(CONFIG.DATE_CONVERSION_LOCALES, CONFIG.DATE_CONVERSION_OPTIONS);
    return true;
  };

  /** INTERCEPT EARLY FOR CERTAIN ROUTES **/

  const path = location.pathname;

  // Codes beyond this block will not execute for this route (mainly for efficiency).
  if (path.startsWith('/bookmark_add.php')) {
    if (CONFIG.DATE_CONVERSION) {
      GM_addStyle(addPageDateStyle);

      const date = document.querySelector('.bookmark-detail-unit .meta');
      // This page has the date display hardcoded to Japan time without an accompanying timestamp.
      convertDate(date, true);
    }

    log(`/bookmark_add.php path detected. Excluding date conversion, script has terminated early.`);
    return;
  }

  /** MAIN UTILS **/

  // GMCompat compatibility shim
  // adapted from https://github.com/chocolateboy/gm-compat
  const $unsafeWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow.wrappedJSObject || unsafeWindow : window;
  const GMCompat = Object.freeze({
    unsafeWindow: $unsafeWindow,
    CLONE_INTO_OPTIONS: {
        cloneFunctions: true,
        target: $unsafeWindow,
        wrapReflectors: true
    },
    EXPORT_FUNCTION_OPTIONS: {
        target: $unsafeWindow
    },
    apply: function ($this, fn, _args) {
        const args = [].slice.call(_args);
        return fn.apply($this, this.cloneInto(args));
    },
    call: function ($this, fn, ..._args) {
        const args = this.cloneInto(_args);
        return fn.call($this, ...args);
    },
    cloneInto: function (object, _options) {
        const options = Object.assign({}, this.CLONE_INTO_OPTIONS, _options);
        const _cloneInto = (typeof cloneInto === 'function') ? cloneInto : object => object;
        return _cloneInto(object, options.target, options);
    },
    export: function (value, options) {
        return (typeof value === 'function')
            ? this.exportFunction(value, options)
            : this.cloneInto(value, options);
    },
    exportFunction: function (fn, _options) {
        const options = Object.assign({}, this.EXPORT_FUNCTION_OPTIONS, _options);
        const _exportFunction = (typeof exportFunction === 'function')
            ? exportFunction
            : (fn, { defineAs, target = this.unsafeWindow } = {}) => { return defineAs ? (target[defineAs] = fn) : fn };
        return _exportFunction(fn, options.target, options);
    },
    unwrap: function (value) {
        return value ? (value.wrappedJSObject || value) : value;
    }
  });

  const patchHistory = () => {
    ['pushState', 'replaceState'].forEach(method => {
      const original = GMCompat.unsafeWindow.history[method];
      const patched = function () {
        GMCompat.apply(this, original, arguments);
        notify(method, arguments[2]);
      };
      GMCompat.unsafeWindow.history[method] = GMCompat.export(patched);
    });

    window.addEventListener('popstate', e => {
      notify(e.type);
    });
  };

  // Navigation detection & CustomEvent dispatch.
  let _OLD_URL;
  const notify = (method, url) => {
    const absUrl = new URL(url || window.location.href, window.location.origin).href;
    const detail = GMCompat.export({ method: method, oldUrl: _OLD_URL, newUrl: absUrl });
    const event = new CustomEvent('detectnavigate', { bubbles: true, detail: detail });
    document.dispatchEvent(event);
    _OLD_URL = absUrl;
  };

  if (window.navigation) {
    logDebug('Using Navigation API.');
    window.navigation.addEventListener('navigatesuccess', e => {
      notify(e.type, e.currentTarget.currentEntry.url);
    });
  } else if (window.onurlchange === null) {
    logDebug('Using window.onurlchange.');
    window.addEventListener('urlchange', e => {
      notify('urlchange', e.url);
    });
  } else {
    logDebug('Using patchHistory().');
    patchHistory();
  }

  /** MAIN STYLES **/

  // To properly handle "&" CSS keyword, in context of also having to support user-defined custom values.
  // Somewhat overkill, but I'm out of ideas.
  const _formatSelectorsMultiViewControls = () => {
    const multiViews = CONFIG.SELECTORS_MULTI_VIEW.split(', ');
    const multiViewsControls = CONFIG.SELECTORS_MULTI_VIEW_CONTROLS.split(', ');

    const formatted = [];
    for (const x of multiViews) {
      for (const y of multiViewsControls) {
        let z = y;
        if (y.startsWith('&')) {
          z = y.substring(1)
        }
        formatted.push(`${x} ${z.trim()}`);
      }
    }
    return formatted;
  };

  const mainStyle = /*css*/`
  .flex:has(+.pu_edit_bookmark_container) {
    flex-grow: 1;
  }

  .pu_edit_bookmark {
    color: rgb(245, 245, 245);
    background: rgba(0, 0, 0, 0.32);
    display: block;
    box-sizing: border-box;
    padding: 0px 6px;
    margin-top: 7px;
    margin-right: 2px;
    border-radius: 10px;
    font-weight: bold;
    font-size: 10px;
    line-height: 20px;
    height: 20px;
    cursor: pointer;
    user-select: none;
  }

  ${CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS.split(', ').map(s => `${s} .pu_edit_bookmark`).join(', ')},
  ${_formatSelectorsMultiViewControls().map(s => `${s} .pu_edit_bookmark`).join(', ')} {
    font-size: 12px;
    height: 24px;
    line-height: 24px;
    margin-top: 5px;
    margin-right: 7px;
  }

  ._layout-thumbnail .pu_edit_bookmark {
    position: absolute;
    right: calc(50% - 71px);
    bottom: 4px;
    z-index: 2;
  }

  .ranking-item.muted .pu_edit_bookmark {
    display: none;
  }

  .sc-s8zj3z-3:has(+ .pu_toggle_bookmarked_container) {
    flex-grow: 1;
    justify-content: flex-end;
  }

  .pu_toggle_bookmarked {
    color: rgb(245, 245, 245);
    background: rgb(58, 58, 58);
    display: block;
    box-sizing: border-box;
    padding: 6px;
    border-radius: 10px;
    font-weight: bold;
    margin-left: 12px;
    cursor: pointer;
    user-select: none;
  }

  .pu_toggle_bookmarked span {
    padding-left: 6px;
  }

  ${CONFIG.SELECTORS_IMAGE_CONTROLS} {
    display: flex;
    justify-content: flex-end;
  }
  `;

  const mainDateStyle = /*css*/`
  .dqHJfP {
    font-size: 14px !important;
    font-weight: bold;
    color: rgb(214, 214, 214) !important;
  }
  `;

  /** UTAGS INTEGRATION INIT **/

  const mainUtagsStyle = /*css*/`
  .pu_blocked_image {
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100%;
    height: 100%;
    border-radius: 4px;
    color: rgb(92, 92, 92);
    background-color: rgb(0, 0, 0);
  }

  .pu_blocked_image svg {
    fill: currentcolor;
  }

  .pu_image_is_blocked .sc-eacaaccb-1 {
    width: 184px;
    height: 184px;
  }

  .ranking-item.pu_image_is_blocked .work {
    width: 150px;
    height: 150px;
  }

  ${CONFIG.SELECTORS_IMAGE_TITLE.split(', ').map(s => `.pu_image_is_blocked ${s}`).join(', ')} {
    color: rgb(133, 133, 133) !important;
  }

  .ranking-item.pu_image_is_blocked ._illust-series-title-text {
    display: none;
  }

  ${CONFIG.SELECTORS_IMAGE_ARTIST_AVATAR.split(', ').map(s => `.pu_image_is_blocked ${s}`).join(', ')} {
    display: none;
  }

  ${CONFIG.SELECTORS_IMAGE_CONTROLS.split(', ').map(s => `.pu_image_is_blocked ${s}`).join(', ')} {
    display: none;
  }
  `;

  const SELECTORS_UTAGS = CONFIG.UTAGS_BLOCKED_TAGS.map(s => `[data-utags_tag="${s}"]`).join(', ');

  log('SELECTORS_UTAGS =', SELECTORS_UTAGS);

  const BLOCKED_IMAGE_HTML = `
  <div radius="4" class="pu_blocked_image">
    <svg viewBox="0 0 24 24" style="width: 48px; height: 48px;">
      <path d="M5.26763775,4 L9.38623853,11.4134814 L5,14.3684211 L5,18 L13.0454155,18 L14.1565266,20 L5,20
  C3.8954305,20 3,19.1045695 3,18 L3,6 C3,4.8954305 3.8954305,4 5,4 L5.26763775,4 Z M9.84347336,4 L19,4
  C20.1045695,4 21,4.8954305 21,6 L21,18 C21,19.1045695 20.1045695,20 19,20 L18.7323623,20 L17.6212511,18
  L19,18 L19,13 L16,15 L15.9278695,14.951913 L9.84347336,4 Z M16,7 C14.8954305,7 14,7.8954305 14,9
  C14,10.1045695 14.8954305,11 16,11 C17.1045695,11 18,10.1045695 18,9 C18,7.8954305 17.1045695,7 16,7 Z
  M7.38851434,1.64019979 L18.3598002,21.3885143 L16.6114857,22.3598002 L5.64019979,2.61148566
  L7.38851434,1.64019979 Z"></path>
    </svg>
  </div>
  `;

  /** MAIN **/

  GM_addStyle(mainStyle);

  if (CONFIG.DATE_CONVERSION) {
    GM_addStyle(mainDateStyle);
  }

  if (CONFIG.UTAGS_INTEGRATION) {
    GM_addStyle(mainUtagsStyle);
  }

  class FunctionQueue {
    constructor() {
      this.queue = [];
      this.running = false;
    }

    async go() {
      if (this.queue.length) {
        this.running = true;
        const _func = this.queue.shift();
        await _func[0](..._func[1]);
        this.go();
      } else {
        this.running = false;
      }
    }

    add(func, ...args) {
      this.queue.push([func, [...args]]);

      if (!this.running) {
        this.go();
      }
    }

    clear() {
      this.queue.length = 0;
    }
  };

  const observerFactory = option => {
    let options;
    if (typeof option === 'function') {
      options = {
        callback: option,
        node: document.getElementsByTagName('body')[0],
        option: { childList: true, subtree: true }
      };
    } else {
      options = $.extend({
        callback: () => {},
        node: document.getElementsByTagName('body')[0],
        option: { childList: true, subtree: true }
      }, option);
    }
    const MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;

    const observer = new MutationObserver((mutations, observer) => {
      options.callback.call(this, mutations, observer);
    });

    observer.observe(options.node, options.option);
    return observer;
  };

  const editBookmarkButton = (id, isNovel = false) => {
    const buttonContainer = document.createElement('div');
    buttonContainer.className = 'pu_edit_bookmark_container';

    const button = document.createElement('a');
    button.className = 'pu_edit_bookmark';
    button.innerText = CONFIG.TEXT_EDIT_BOOKMARK;

    if (CONFIG.TEXT_EDIT_BOOKMARK_TOOLTIP) {
      button.title = CONFIG.TEXT_EDIT_BOOKMARK_TOOLTIP;
    }

    if (isNovel) {
      button.href = `https://www.pixiv.net/novel/bookmark_add.php?id=${id}`;
    } else {
      button.href = `https://www.pixiv.net/bookmark_add.php?type=illust&illust_id=${id}`;
    }

    buttonContainer.appendChild(button);
    return buttonContainer;
  };

  const findLink = element => {
    return element.querySelector('a[href*="artworks/"]');
  };

  const findNovelLink = element => {
    return element.querySelector('a[href*="novel/show.php?id="]');
  };

  const findItemId = element => {
    let id = null;
    let isNovel = false;

    let link = findLink(element);
    if (link) {
      const match = link.href.match(/artworks\/(\d+)/);
      id = match ? match[1] : null;
    } else {
      link = findNovelLink(element);
      if (link) {
        const match = link.href.match(/novel\/show\.php\?id=(\d+)/);
        id = match ? match[1] : null;
        isNovel = true;
      }
    }

    return { id, isNovel };
  };

  const isElementVisible = element => {
    if (!element || !element.isConnected) {
      return false;
    }

    return element.checkVisibility();
  };

  // 0 = show all
  // 1 = show not bookmarked
  // 2 = show bookmarked
  // bookmarked: .bXjFLc
  // not bookmarked: .fYcrPo
  const isImageBookmarked = element => {
    return Boolean(element.querySelector('.bXjFLc'));
  };

  const doImage = (element, isHome = false) => {
    if (!isElementVisible(element)) {
      return false;
    }

    if (CONFIG.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME && isHome) {
      if (findNovelLink(element)) {
        element.style.display = 'none';
        return true;
      }
    }

    // Process new entries in toggled bookmarked sections.
    if (element.closest('.pu_toggle_bookmarked_section')) {
      const mode = GM_getValue('PREF_TOGGLE_BOOKMARKED_MODE', 0);
      if (mode === 1) {
        element.style.display = isImageBookmarked(element) ? 'none': '';
      } else if (mode === 2) {
        element.style.display = isImageBookmarked(element) ? '': 'none';
      }
    }

    // Skip if edit bookmark button already inserted.
    if (element.querySelector('.pu_edit_bookmark')) {
      return false;
    }

    const imageControls = element.querySelector(CONFIG.SELECTORS_IMAGE_CONTROLS);
    if (!imageControls) {
      return false;
    }

    const { id, isNovel } = findItemId(element);
    if (id !== null) {
      imageControls.insertBefore(editBookmarkButton(id, isNovel), imageControls.firstChild);
      return true;
    }

    return false;
  };

  const doMultiView = (element, isHome = false) => {
    if (!isElementVisible(element)) {
      return false;
    }

    if (CONFIG.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME && isHome) {
      if (findNovelLink(element)) {
        element.parentNode.style.display = 'none';
        return true;
      }
    }

    // Skip if edit bookmark button already inserted.
    if (element.querySelector('.pu_edit_bookmark')) {
      return false;
    }

    const multiViewControls = element.querySelector(CONFIG.SELECTORS_MULTI_VIEW_CONTROLS);
    if (!multiViewControls) {
      return false;
    }

    const { id, isNovel } = findItemId(element);
    if (id !== null) {
      multiViewControls.insertBefore(editBookmarkButton(id, isNovel), multiViewControls.lastChild);
      return true;
    }

    return false;
  };

  const doExpandedViewControls = element => {
    if (!isElementVisible(element)) {
      return false;
    }

    // Skip if edit bookmark button already inserted.
    if (element.querySelector('.pu_edit_bookmark')) {
      return false;
    }

    let id = null;
    let isNovel = false;

    let match = window.location.href.match(/artworks\/(\d+)/);
    if (match && match[1]) {
      id = match[1];
    } else {
      match = window.location.href.match(/novel\/show\.php\?id=(\d+)/);
      if (match && match[1]) {
        id = match[1];
        isNovel = true;
      }
    }

    if (id !== null) {
      element.appendChild(editBookmarkButton(id, isNovel));
      return true;
    }

    return false;
  };

  const formatToggleBookmarkedButtonHtml = mode => {
    if (mode === 0) {
      return /*html*/`${CONFIG.TEXT_TOGGLE_BOOKMARKED}<span>${CONFIG.TEXT_TOGGLE_BOOKMARKED_SHOW_ALL}<span>`;
    } else if (mode === 1) {
      return /*html*/`${CONFIG.TEXT_TOGGLE_BOOKMARKED}<span>${CONFIG.TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED}<span>`;
    } else if (mode === 2) {
      return /*html*/`${CONFIG.TEXT_TOGGLE_BOOKMARKED}<span>${CONFIG.TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED}<span>`;
    }
  }

  let toggling = false;
  const toggleBookmarked = (button, parent, header, imagesContainer, rightClick = false) => {
    if (toggling) {
      return false;
    }

    toggling = true;

    let mode = GM_getValue('PREF_TOGGLE_BOOKMARKED_MODE', 0);
    if (rightClick) { mode--; } else { mode++; }
    if (mode > 2) { mode = 0; } else if (mode < 0) { mode = 2; }

    button.innerHTML = formatToggleBookmarkedButtonHtml(mode);

    let images = Array.from(imagesContainer.querySelectorAll(CONFIG.SELECTORS_IMAGE));

    // Do not process blocked images if they are already forcefully hidden.
    if (CONFIG.UTAGS_REMOVE_BLOCKED) {
      images = images.filter(image => !image.classList.contains('pu_image_is_blocked'));
    }

    if (mode === 0) {
      for (const image of images) {
        image.style.display = '';
      }
    } else if (mode === 1) {
      for (const image of images) {
        if (image.classList.contains('pu_image_is_blocked') || isImageBookmarked(image)) {
          image.style.display = 'none';
        } else {
          image.style.display = '';
        }
      }
    } else if (mode === 2) {
      for (const image of images) {
        if (image.classList.contains('pu_image_is_blocked') || !isImageBookmarked(image)) {
          image.style.display = 'none';
        } else {
          image.style.display = '';
        }
      }
    }

    GM_setValue('PREF_TOGGLE_BOOKMARKED_MODE', mode);

    toggling = false;

    return true;
  };

  const doToggleBookmarkedSection = (element, sectionConfig) => {
    // Skip if already processed.
    if (element.classList.contains('pu_toggle_bookmarked_section')) {
      return false;
    }

    const header = element.querySelector(sectionConfig.selectorHeader);
    const imagesContainer = element.querySelector(sectionConfig.selectorImagesContainer);

    if (!header || !imagesContainer) {
      return false;
    }

    // Mark as processed.
    element.classList.add('pu_toggle_bookmarked_section');

    const buttonContainer = document.createElement('div');
    buttonContainer.className = 'pu_toggle_bookmarked_container';

    const button = document.createElement('a');
    button.className = 'pu_toggle_bookmarked';
    button.innerHTML = formatToggleBookmarkedButtonHtml(GM_getValue('PREF_TOGGLE_BOOKMARKED_MODE', 0));

    if (CONFIG.TEXT_TOGGLE_BOOKMARKED_TOOLTIP) {
      button.title = CONFIG.TEXT_TOGGLE_BOOKMARKED_TOOLTIP;
    }

    button.addEventListener('click', event => toggleBookmarked(button, element, header, imagesContainer));
    button.addEventListener('contextmenu', event => {
      event.preventDefault();
      toggleBookmarked(button, element, header, imagesContainer, true);
    });

    buttonContainer.appendChild(button);

    header.appendChild(buttonContainer);

    return true;
  };

  const doUtagsImage = element => {
    if (!isElementVisible(element)) {
      return false;
    }

    const image = element.closest(CONFIG.SELECTORS_IMAGE);
    if (image) {
      const imageLink = image.querySelector('a[href*="artworks/"], a[href*="novel/"]');
      if (!imageLink) {
        return false;
      }

      // Skip if already blocked.
      if (image.classList.contains('pu_image_is_blocked')) {
        return false;
      }

      image.classList.add('pu_image_is_blocked');

      if (CONFIG.UTAGS_REMOVE_BLOCKED) {
        image.style.display = 'none';
        return true;
      }

      imageLink.innerHTML = BLOCKED_IMAGE_HTML;

      const imageTitle = image.querySelector(CONFIG.SELECTORS_IMAGE_TITLE);
      if (imageTitle) {
        if (element.dataset.utags_tag === "hide") {
          imageTitle.innerText = 'Hidden';
        } else {
          // block tag and custom tags
          imageTitle.innerText = 'Blocked';
        }
      }

      // Empty the text instead of hiding it, so that the utags will still display properly to provide context.
      const artistLink = image.querySelector(CONFIG.SELECTORS_IMAGE_ARTIST_NAME);
      if (artistLink) {
        artistLink.innerText = '';
      }

      return true;
    }

    const multiView = element.closest(CONFIG.SELECTORS_MULTI_VIEW);
    if (multiView) {
      // For multi view artwork, just hide the whole entry instead.
      multiView.parentNode.style.display = 'none';
      return true;
    }

    const artistHeader = element.closest('.ggHNyV');
    if (artistHeader) {
      const followButton = artistHeader.querySelector('.irfecv:not([disabled])');
      if (followButton) {
        // This does not disable Pixiv's built-in "F" keybind.
        followButton.disabled = true;
        return true;
      }
    }

    return false;
  };

  const triggerQueue = new FunctionQueue();

  window.addEventListener('detectnavigate', event => {
    triggerQueue.clear();
    logDebug('Cleared pending trigger queue.');
  });

  observerFactory((...args) => {
    triggerQueue.add((mutations, observer) => {
      for (let i = 0, len = mutations.length; i < len; i++) {
        const mutation = mutations[i];

        // Whether to change nodes.
        if (mutation.type !== 'childList') {
          continue;
        }

        //const targetParent = mutation.target.parentElement || mutation.target;

        const isHome = Boolean(mutation.target.closest('[data-ga4-label="page_root"]'));

        // Expanded View Controls
        let expandedViewControls = null;

        if (mutation.target.matches(CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS)) {
          expandedViewControls = mutation.target;
        } else {
          expandedViewControls = mutation.target.querySelector(CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS);
        }

        if (expandedViewControls && doExpandedViewControls(expandedViewControls)) {
          log(`Processed expanded view controls.`);
        }

        // Images
        let _image = 0;

        if (mutation.target.matches(CONFIG.SELECTORS_IMAGE)) {
          if (doImage(mutation.target, isHome)) {
            _image++;
          }
        } else {
          const images = mutation.target.querySelectorAll(CONFIG.SELECTORS_IMAGE);
          for (const image of images) {
            if (doImage(image, isHome)) {
              _image++;
            }
          }
        }

        if (_image > 0) {
          log(`Processed ${_image} image(s).`);
        }

        // Multi Views
        let _multiView = 0;

        if (mutation.target.matches(CONFIG.SELECTORS_MULTI_VIEW)) {
          if (doMultiView(mutation.target, isHome)) {
            _multiView++;
          }
        } else {
          const multiViews = mutation.target.querySelectorAll(CONFIG.SELECTORS_MULTI_VIEW);
          for (const multiView of multiViews) {
            if (doMultiView(multiView, isHome)) {
              _multiView++;
            }
          }
        }

        if (_multiView > 0) {
            log(`Processed ${_multiView} multi view(s).`);
        }

        // Toggle Bookmarked
        let _toggleBookmarked = 0;

        for (const sectionConfig of CONFIG.SECTIONS_TOGGLE_BOOKMARKED) {
          if (!sectionConfig.selectorParent || !sectionConfig.selectorHeader || !sectionConfig.selectorImagesContainer) {
            logDebug('Invalid "SECTIONS_TOGGLE_BOOKMARKED" config', sectionConfig);
            continue;
          }

          let parents = [];
          if (mutation.target.matches(sectionConfig.selectorParent)) {
            parents = [mutation.target];
          } else {
            parents = mutation.target.querySelectorAll(sectionConfig.selectorParent);
          }

          if (!parents || !parents.length) {
            continue;
          }

          for (const parent of parents) {
            if (doToggleBookmarkedSection(parent, sectionConfig)) {
              _toggleBookmarked++;
            }
          }
        }

        if (_toggleBookmarked > 0) {
          log(`Processed ${_toggleBookmarked} toggle bookmarked section(s).`);
        }

        // Dates
        if (CONFIG.DATE_CONVERSION) {
          let _date = 0;

          const dates = mutation.target.querySelectorAll(CONFIG.SELECTORS_DATE);
          for (const date of dates) {
            if (convertDate(date)) {
              _date++;
            }
          }

          if (_date > 0) {
            log(`Processed ${_date} date element(s).`);
          }
        }


        // UTags integration
        if (CONFIG.UTAGS_INTEGRATION) {
          let _utag = 0;

          if (mutation.target.matches(SELECTORS_UTAGS)) {
            if (doUtagsImage(mutation.target)) {
              _utag++;
            }
          } else {
            const utags = mutation.target.querySelectorAll(SELECTORS_UTAGS);
            for (const utag of utags) {
              if (doUtagsImage(utag)) {
                _utag++;
              }
            }
          }

          if (_utag > 0) {
            log(`Processed ${_utag} UTag(s).`);
          }
        }
      }
    }, ...args);
  });

  /** KEYBINDS **/

  if (CONFIG.ENABLE_KEYBINDS) {
    let onCooldown = {};

    const processKeyEvent = (id, element) => {
      if (!element) {
        return false;
      }

      if (onCooldown[id]) {
        log(`"${id}" keybind still on cooldown.`);
        return false;
      }

      onCooldown[id] = true;
      element.click();
      setTimeout(() => { onCooldown[id] = false }, 1000);
    }

    document.addEventListener('keydown', event => {
      event = event || window.event;

      // Ignore keybinds when currently focused to an input/textarea/editable element.
      if (document.activeElement && (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) || document.activeElement.isContentEditable)) {
        return;
      }

      // "Shift+B" for Edit Bookmark.
      // Pixiv has built-in keybind "B" for just bookmarking.
      if (event.keyCode === 66) {
        if (event.ctrlKey || event.altKey) {
          // Ignore "Ctrl+B" or "Alt+B".
          return;
        }
        if (event.shiftKey) {
          event.stopPropagation();
          const element = document.querySelector(CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS + ' .pu_edit_bookmark');
          return processKeyEvent('bookmarkEdit', element);
        }
      }
    });

    logDebug('Listening for keybinds.');
  } else {
    logDebug('Keybinds disabled.');
  }

})()