Bobby's Pixiv Utils

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

当前为 2025-03-19 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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.6
// @author      Bobby Wibowo
// @license     MIT
// @description 7/2/2024, 8:37:14 PM
// @noframes
// ==/UserScript==

/* global document, console, location, setTimeout, unsafeWindow, window, 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_IMAGE_BOOKMARKED: GM_getValue('SELECTORS_IMAGE_BOOKMARKED'),
    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'),
    SELECTORS_FOLLOW_BUTTON_CONTAINER: GM_getValue('SELECTORS_FOLLOW_BUTTON_CONTAINERS'),
    SELECTORS_FOLLOW_BUTTON: GM_getValue('SELECTORS_FOLLOW_BUTTON'),

    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
   * Bookmarked: .bXjFLc
   *
   * 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
   * Bookmarked: ._one-click-bookmark.on
   *
   * Newest by all page:
   * Image: .sc-e6de33c8-0 > li
   * Bookmarked: .epoVSE
   */
  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, .sc-e6de33c8-0 > li',
    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_IMAGE_BOOKMARKED: '.bXjFLc, ._one-click-bookmark.on, .epoVSE',
    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_FOLLOW_BUTTON_CONTAINER: '.sc-gulj4d-2, .sc-k3uf3r-3, .sc-10gpz4q-3, .sc-f30yhg-3',
    SELECTORS_FOLLOW_BUTTON: '[data-click-label="follow"]:not([disabled])',
    SELECTORS_DATE: '.sc-5981ly-1',

    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'
      },
      // Rankings page
      {
        selectorParent: '#wrapper ._unit',
        selectorHeader: '.ranking-menu',
        selectorImagesContainer: '.ranking-items-container'
      },
      // Newest by all page
      {
        selectorParent: '.sc-7b5ed552-0',
        selectorHeader: '.sc-f08ce4e3-2',
        selectorImagesContainer: '.sc-a7a11491-1'
      }
    ],

    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),
  .sc-7c5ab71e-2:has(+ .pu_toggle_bookmarked_container) {
    flex-grow: 1;
    justify-content: flex-end;
  }

  .pu_toggle_bookmarked_container {
    text-align: center;
  }

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

  .pu_toggle_bookmarked:hover {
    text-decoration: 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.append(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();
  };

  // Toggle Bookmarked Modes.
  // 0 = Show all
  // 1 = Show not bookmarked
  // 2 = Show bookmarked
  const _TB_MIN = 0;
  const _TB_MAX = 2;

  const isImageBookmarked = element => {
    return element.querySelector(CONFIG.SELECTORS_IMAGE_BOOKMARKED) !== null;
  };

  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';
        logDebug('Removed novel recommendation from home', element);
        return true;
      }
    }

    // Process new entries in toggled bookmarked sections.
    if (element.closest('.pu_toggle_bookmarked_section')) {
      const mode = GM_getValue('PREF_TOGGLE_BOOKMARKED_MODE', _TB_MIN);
      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.prepend(editBookmarkButton(id, isNovel));
      return true;
    }

    return false;
  };

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

    // Skip if this multi view entry is still being generated.
    if (!element.querySelector('a[href]')) {
      return false;
    }

    if (CONFIG.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME && isHome) {
      if (findNovelLink(element)) {
        element.parentNode.style.display = 'none';
        logDebug('Removed novel recommendation from home', element);
        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.lastChild.before(editBookmarkButton(id, isNovel));
      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.append(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', _TB_MIN);
    if (rightClick) { mode--; } else { mode++; }
    if (mode > _TB_MAX) { mode = _TB_MIN; } else if (mode < _TB_MIN) { mode = _TB_MAX; }

    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', _TB_MIN));

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

    // Left click.
    button.addEventListener('click', event => toggleBookmarked(button, element, header, imagesContainer));

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

    buttonContainer.append(button);
    header.append(buttonContainer);
    return true;
  };

  const doUtags = 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';
      logDebug('Removed multi view entry due to UTag', element);
      return true;
    }

    const followButtonContainer = element.closest(CONFIG.SELECTORS_FOLLOW_BUTTON_CONTAINER);
    if (followButtonContainer) {
      const followButton = followButtonContainer.querySelector(CONFIG.SELECTORS_FOLLOW_BUTTON);
      if (followButton) {
        // Cosmetic only. This will not disable Pixiv's built-in "F" keybind.
        followButton.disabled = true;
        // Return early since there will only be one follow button per container.
        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 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 (doUtags(mutation.target)) {
              _utag++;
            }
          } else {
            const utags = mutation.target.querySelectorAll(SELECTORS_UTAGS);
            for (const utag of utags) {
              if (doUtags(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.');
  }

})()