Bobby's Pixiv Utils

Compatible with mobile. "Edit bookmark" and "Toggle bookmarked" buttons, publish dates conversion, block AI-generated works, block by Pixiv tags, UTags integration, and more!

当前为 2025-05-26 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴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
// @version      1.6.15
// @description  Compatible with mobile. "Edit bookmark" and "Toggle bookmarked" buttons, publish dates conversion, block AI-generated works, block by Pixiv tags, UTags integration, and more!
// @author       Bobby Wibowo
// @license      MIT
// @match        *://www.pixiv.net/*
// @exclude      *://www.pixiv.net/setting*
// @exclude      *://www.pixiv.net/manage*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=pixiv.net
// @run-at       document-start
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        window.onurlchange
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/sentinel.min.js
// @noframes
// ==/UserScript==

/* global sentinel */

(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 **/

  /* It's recommended to edit these values through your userscript manager's storage/values editor.
   * Visit Pixiv once after installing the script to allow it to populate its storage with default values.
   * Especially necessary for Tampermonkey to show the script's Storage tab when Advanced mode is turned on.
   */
  const ENV_DEFAULTS = {
    MODE: 'PROD',

    TEXT_EDIT_BOOKMARK: '✏️',
    TEXT_EDIT_BOOKMARK_TOOLTIP: 'Edit bookmark',

    TEXT_TOGGLE_BOOKMARKED: '❤️',
    TEXT_TOGGLE_BOOKMARKED_TOOLTIP: 'Cycle bookmarked display (Right-Click to cycle back)',
    TEXT_TOGGLE_BOOKMARKED_SHOW_ALL: 'Show all',
    TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED: 'Show bookmarked',
    TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED: 'Show not bookmarked',

    SELECTORS_HOME: null,
    SELECTORS_OWN_PROFILE: null,
    SELECTORS_IMAGE: null,
    SELECTORS_IMAGE_TITLE: null,
    SELECTORS_IMAGE_ARTIST_AVATAR: null,
    SELECTORS_IMAGE_ARTIST_NAME: null,
    SELECTORS_IMAGE_CONTROLS: null,
    SELECTORS_IMAGE_BOOKMARKED: null,
    SELECTORS_EXPANDED_VIEW_IMAGE: null,
    SELECTORS_EXPANDED_VIEW_CONTROLS: null,
    SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE: null,
    SELECTORS_MULTI_VIEW: null,
    SELECTORS_MULTI_VIEW_CONTROLS: null,
    SELECTORS_FOLLOW_BUTTON_CONTAINER: null,
    SELECTORS_FOLLOW_BUTTON: null,
    SELECTORS_RECOMMENDED_USER_CONTAINER: null,

    DATE_CONVERSION: true,
    DATE_CONVERSION_LOCALES: 'en-GB',
    DATE_CONVERSION_OPTIONS: {
      hour12: true,
      year: 'numeric',
      month: 'long',
      day: 'numeric',
      hour: '2-digit',
      minute: '2-digit'
    },
    SELECTORS_DATE: null,

    REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME: false,

    SECTIONS_TOGGLE_BOOKMARKED: null,

    ENABLE_KEYBINDS: true,

    PIXIV_HIGHLIGHTED_TAGS: null,
    PIXIV_HIGHLIGHTED_COLOR: '#32cd32',

    PIXIV_BLOCK_AI: false,
    PIXIV_BLOCKED_TAGS: null,
    // Instead of merely masking them à la Pixiv's built-in tags mute.
    PIXIV_REMOVE_BLOCKED: false,

    UTAGS_INTEGRATION: true,
    UTAGS_BLOCKED_TAGS: null,
    // Instead of merely masking them à la Pixiv's built-in tags mute.
    UTAGS_REMOVE_BLOCKED: false
  };

  /* Hard-coded preset values.
   * Specifying custom values will extend instead of replacing them.
   */
  const PRESETS = {
    // Keys that starts with "SELECTORS_", and in array, will automatically be converted to single-line strings.
    SELECTORS_HOME: '[data-ga4-label="page_root"]',

    SELECTORS_OWN_PROFILE: [
      'a[href*="settings/profile"]', // desktop
      '.ui-button[href*="setting_profile.php"]' // mobile
    ],

    SELECTORS_IMAGE: [
      'li[data-ga4-label="thumbnail"]', // home's latest works grid
      '.sc-96f10c4f-0 > li', // home's recommended works grid
      '.eyusRs > div', // user profile popup
      '.jELUak > li', // artist page's grid
      '.iHrRmI > li', // artist page's featured works
      '.ibaIoN > div:has(a[href])', // expanded view's recommended works after pop-in
      '.hGHlmK > div', // expanded view's other works sidebar
      '.iwHaa-d > li', // tags page's grid
      '.jClpXN > li', // tags page's grid (novel)
      '.gLWzLO > div', // tags page's users tab
      '.fhUcsb > li', // "newest by all" page
      '.buGhFj > li', // requests page
      '.bkRoSP > li', // manga page's followed works
      '.kAeevZ > .fjXNAo', // manga page's daily ranking
      '.dHJLGd > div', // novels page's ongoing contests
      '.ranking-item', // rankings page
      '._ranking-item', // rankings page (novel)
      '.works-item-illust:has(.thumb:not([src^="data"]))', // mobile
      '.works-item:not(.works-item-illust):has(.thumb:not([src^="data"]))', // mobile (novel)
      '.works-item-novel-editor-recommend:has(.cover:not([style^="data"]))', // mobile's novels page's editor's picks
      '.stacclist > li.illust' // mobile's feed page
    ],

    SELECTORS_IMAGE_TITLE: [
      '[data-ga4-label="title_link"]', // home's recommended works grid
      '.gtm-illust-recommend-title', // discovery page's grid
      '.kmUlkw', // tags/bookmarks page's grid
      '.title', // rankings page
      '.illust-info > a[class*="c-text"]' // mobile list view
    ],

    SELECTORS_IMAGE_ARTIST_AVATAR: [
      '[data-ga4-label="user_icon_link"]', // home's recommended works grid
      '.sc-1rx6dmq-1', // expanded view's related works grid
      '.lbFgXO', // tags/bookmarks page's grid
      '._user-icon' // rankings page
    ],

    SELECTORS_IMAGE_ARTIST_NAME: [
      '[data-ga4-label="user_name_link"]', // home's recommended works grid
      '.gtm-illust-recommend-user-name', // expanded view's related works grid
      '.QzTPT', // tags/bookmarks page's grid
      '.user-name', // rankings page
      '.illust-author' // mobile list view
    ],

    SELECTORS_IMAGE_CONTROLS: [
      '.ldNztP', // home's latest/recommended works grid
      '.ppQNN', // discovery page's grid
      '.hLHrVH', // artist page's grid
      '.fRrNLv', // artist page's featured works
      '.cgYJXZ', // tags page's grid (novel)
      '.ZBDKi', // "newest by all" page
      '.byWzRq', // expanded view's artist bottom bar (novel)
      '.Yzjmx', // artist page's grid (novel)
      '.jVTssb', // artist page's featured works (novel)
      '.hFAmSK', // novels page
      '.djUdtd > div:last-child', // novels page's editor's picks
      '.gAyuNi', // novels page's ongoing contests
      '._layout-thumbnail', // rankings page
      '.novel-right-contents', // rankings page (novel)
      '.imgoverlay', // mobile's feed page
      '.bookmark', // mobile
      '.hSoPoc' // mobile
    ],

    SELECTORS_IMAGE_BOOKMARKED: [
      '.epoVSE', // desktop
      '.wQCIS', // "newest by all" page
      '._one-click-bookmark.on', // rankings page
      '.works-bookmark-button svg path[fill="#FF4060"]' // mobile
    ],

    SELECTORS_EXPANDED_VIEW_IMAGE: [
      '.cxsjmo', // desktop
      '.illust-details-view' // mobile
    ],

    SELECTORS_EXPANDED_VIEW_CONTROLS: [
      '.inxZPA', // desktop
      '.work-interactions' // mobile
    ],

    SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE: '.eoaxji > div:has(a[href])',

    SELECTORS_MULTI_VIEW: '[data-ga4-label="work_content"]:has(a[href])',

    SELECTORS_MULTI_VIEW_CONTROLS: '& > .w-full:last-child > .flex:first-child > .flex-row:first-child',

    SELECTORS_FOLLOW_BUTTON_CONTAINER: [
      '.XFDNu', // artist page's header
      '.kIkMnj', // artist hover popup
      '.gSkxA', // expanded view's artist bottom bar
      '.cmowxU', // expanded view's artist sidebar
      '.user-details' // mobile's artist page
    ],

    SELECTORS_FOLLOW_BUTTON: [
      '[data-click-label="follow"]:not([disabled])', // desktop
      '.ui-button' // mobile
    ],

    SELECTORS_RECOMMENDED_USER_CONTAINER: [
      // home's recommended users sidebar
      '.hSNbaL > .grid > :nth-child(2) .flex-col.gap-8:first-child .flex-row.items-center:not(.mr-auto)',
      '.YXoqY .grid li.list-none', // tags page's users tab
    ],

    SELECTORS_DATE: [
      '.dgDuKx', // desktop
      '.kzGSfF', // desktop "updated on" popup
      '.times', // mobile
      '.reupload-info .tooltip-text' // mobile "updated on" popup
    ],

    // Selectors must be single-line strings.
    SECTIONS_TOGGLE_BOOKMARKED: [
      // Following page
      {
        selectorParent: '.icUpwV',
        selectorHeader: '.fHQERN',
        selectorImagesContainer: '.fJdNho'
      },
      // Artist page
      {
        selectorParent: '.gqvfWY:not(.bYCbxa)',
        selectorHeader: '.rXWMQ',
        selectorImagesContainer: '.rXWMQ ~ div:not([class])'
      },
      // Artist page's bookmarks tab
      {
        selectorParent: '.gqvfWY.bYCbxa',
        selectorHeader: '.cfUrtF',
        selectorImagesContainer: '.cfUrtF ~ div:not([class])',
        sanityCheck: () => {
          // Skip if in own profile.
          return document.querySelector('a[href*="settings/profile"]');
        }
      },
      // Tags page
      {
        selectorParent: '.icUpwV',
        selectorHeader: '.dlidhK',
        selectorImagesContainer: '.fxjfKC'
      },
      // "Newest by all" page
      {
        selectorParent: '.YXoqY',
        selectorHeader: '.cwGkEl',
        selectorImagesContainer: '.hairtM '
      },
      // Rankings page
      {
        selectorParent: '#wrapper ._unit',
        selectorHeader: '.ranking-menu',
        selectorImagesContainer: '.ranking-items-container'
      },
      // Mobile artist page's illustrations/bookmarks tab, following page, tags page
      {
        selectorParent: '.v-nav-tabs + div:not(.header-buttons), ' +
          '.nav-tab + div, ' +
          '.search-nav-config + div',
        selectorHeader: '.pager-view-nav',
        selectorImagesContainer: '.works-grid-list',
        sanityCheck: () => {
          // Skip if in own profile (intended for bookmarks page).
          return document.querySelector('.ui-button[href*="setting_profile.php"]');
        }
      },
      // Mobile artist page's home tab
      {
        selectorParent: '.work-set > div',
        selectorHeader: '.title-line > div:last-child',
        selectorImagesContainer: '.works-grid-list'
      },
      // Mobile rankings page
      {
        selectorParent: '.ranking-page',
        selectorHeader: '.header-buttons',
        selectorImagesContainer: '.works-grid-list'
      }
    ],

    // To ensure any custom values will be inserted into array, or combined together if also an array.
    PIXIV_HIGHLIGHTED_TAGS: [],

    PIXIV_BLOCKED_TAGS: [],

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

  const ENV = {};

  // Store default values.
  for (const key of Object.keys(ENV_DEFAULTS)) {
    const stored = GM_getValue(key);
    if (stored === null || stored === undefined) {
      ENV[key] = ENV_DEFAULTS[key];
      GM_setValue(key, ENV_DEFAULTS[key]);
    } else {
      ENV[key] = stored;
    }
  }

  const _DOCUMENT_FRAGMENT = document.createDocumentFragment();
  const queryCheck = selector => _DOCUMENT_FRAGMENT.querySelector(selector);

  const isSelectorValid = selector => {
    try {
      queryCheck(selector);
    } catch {
      return false;
    }
    return true;
  };

  const CONFIG = {};

  // Extend hard-coded preset values with user-defined custom values, if applicable.
  for (const key of Object.keys(ENV)) {
    if (key.startsWith('SELECTORS_')) {
      if (Array.isArray(PRESETS[key])) {
        CONFIG[key] = PRESETS[key].join(', ');
      } else {
        CONFIG[key] = PRESETS[key] || '';
      }
      if (ENV[key]) {
        CONFIG[key] += `, ${Array.isArray(ENV[key]) ? ENV[key].join(', ') : ENV[key]}`;
      }
      if (!isSelectorValid(CONFIG[key])) {
        console.error(`${key} contains invalid selector =`, CONFIG[key]);
        return;
      }
    } else if (Array.isArray(PRESETS[key])) {
      CONFIG[key] = PRESETS[key];
      if (ENV[key]) {
        const customValues = Array.isArray(ENV[key]) ? ENV[key] : ENV[key].split(',').map(s => s.trim());
        CONFIG[key].push(...customValues);
      }
    } else {
      CONFIG[key] = PRESETS[key] || null;
      if (ENV[key] !== null) {
        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',
      'PIXIV_HIGHLIGHTED_TAGS',
      'PIXIV_BLOCK_AI',
      'PIXIV_BLOCKED_TAGS',
      'PIXIV_REMOVE_BLOCKED',
      'UTAGS_INTEGRATION',
      'UTAGS_BLOCKED_TAGS',
      'UTAGS_REMOVE_BLOCKED'
    ];
  } else {
    logDebug = log;
  }

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

  /** GLOBAL UTILS **/

  const addPageDateStyle = /*css*/`
  .bookmark-detail-unit .meta:not([data-pixiv_utils_duplicate_date]) {
    display: none !important;
  }

  .bookmark-detail-unit .meta[data-pixiv_utils_duplicate_date] {
    display: block;
    font-size: 16px;
    font-weight: bold;
    color: inherit;
    margin-left: 0;
    margin-top: 10px;
  }
  `;

  const convertDate = (element, fixJapanTime = false) => {
    // Support "updated on" popups
    const updatedOnRegexes = [
      /(^Image updated on )(.*)$/i // EN
    ];

    let prefix = '';
    let date;
    let dateHasHMS = true;

    let duplicateDate = element.nextElementSibling;
    if (!duplicateDate || !duplicateDate.dataset.pixiv_utils_duplicate_date) {
      duplicateDate = element.cloneNode(true);
      duplicateDate.dataset.pixiv_utils_duplicate_date = true;
      element.after(duplicateDate);
    }

    const attr = element.getAttribute('datetime');
    if (attr) {
      date = new Date(attr);
    } else {
      let dateText = element.innerText.trim();
      if (!dateText.includes(':')) {
        dateHasHMS = false;
      }

      for (const regex of updatedOnRegexes) {
        const _match = dateText.match(regex);
        if (_match) {
          dateText = _match[2];
          prefix = _match[1];
          break;
        }
      }

      // For dates hard-coded to Japan locale.
      const match = dateText.match(/^(\d{4})年(\d{2})月(\d{2})日 (\d{2}:\d{2})$/);
      if (match) {
        dateText = `${match[2]}-${match[3]}-${match[1]} ${match[4]}`;
      }

      // For pages which have the date display hardcoded to Japan time.
      if (fixJapanTime) {
        dateText += ' UTC+9';
      }

      date = new Date(dateText);
    }

    if (!date) {
      return false;
    }

    const timestamp = date.getTime();
    if (Number.isNaN(timestamp) || duplicateDate.dataset.pixiv_utils_date_timestamp === String(timestamp)) {
      return false;
    }

    if (prefix) {
      duplicateDate.dataset.pixiv_utils_date_prefix = prefix;
    }

    let dateConversionOptions = CONFIG.DATE_CONVERSION_OPTIONS;
    if (!dateHasHMS) {
      dateConversionOptions = Object.assign({}, dateConversionOptions);
      delete dateConversionOptions.hour12;
      delete dateConversionOptions.hourCycle;
      delete dateConversionOptions.hour;
      delete dateConversionOptions.minute;
      delete dateConversionOptions.second;
    }

    duplicateDate.dataset.pixiv_utils_date_timestamp = timestamp;
    duplicateDate.innerHTML = prefix + date.toLocaleString(CONFIG.DATE_CONVERSION_LOCALES, dateConversionOptions);
    return true;
  };

  /** INTERCEPT EARLY FOR CERTAIN ROUTES **/

  const waitPageLoaded = () => {
    return new Promise(resolve => {
      if (document.readyState === 'complete' ||
        document.readyState === 'loaded' ||
        document.readyState === 'interactive') {
        resolve();
      } else {
        document.addEventListener('DOMContentLoaded', resolve);
      }
    });
  };

  const path = location.pathname;

  // Codes beyond this block will not execute for these routes (mainly for efficiency).
  if (path.startsWith('/bookmark_add.php') || path.startsWith('/novel/bookmark_add.php')) {
    if (CONFIG.DATE_CONVERSION) {
      waitPageLoaded().then(() => {
        GM_addStyle(addPageDateStyle);
        const date = document.querySelector('.bookmark-detail-unit .meta');
        if (date) {
          // This page has the date display hardcoded to Japan time without an accompanying timestamp.
          convertDate(date, true);
        }
      });
    }

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

  /** MAIN UTILS */

  const SELECTORS_IMAGE_CONTAINER_SIMPLIFIED = [
    '.eyusRs', // user profile popup
    '.hGHlmK' // expanded view's other works sidebar
  ].join(', ');

  const SELECTORS_IMAGE_MOBILE = '.works-item-illust';

  const PIXIV_HIGHLIGHTED_TAGS_STRING = [];
  const PIXIV_HIGHLIGHTED_TAGS_REGEXP = [];

  for (const tag of CONFIG.PIXIV_HIGHLIGHTED_TAGS) {
    if (typeof tag === 'string') {
      PIXIV_HIGHLIGHTED_TAGS_STRING.push(String(tag));
    } else if (Array.isArray(tag)) {
      PIXIV_HIGHLIGHTED_TAGS_REGEXP.push(new RegExp(tag[0], tag[1] || ''));
    }
  }

  logDebug('PIXIV_HIGHLIGHTED_TAGS_STRING = ', PIXIV_HIGHLIGHTED_TAGS_STRING);
  logDebug('PIXIV_HIGHLIGHTED_TAGS_REGEXP = ', PIXIV_HIGHLIGHTED_TAGS_REGEXP);
  const PIXIV_HIGHLIGHTED_TAGS_VALIDATED = PIXIV_HIGHLIGHTED_TAGS_STRING.length || PIXIV_HIGHLIGHTED_TAGS_REGEXP.length;

  const PIXIV_BLOCKED_TAGS_STRING = [];
  const PIXIV_BLOCKED_TAGS_REGEXP = [];

  for (const tag of CONFIG.PIXIV_BLOCKED_TAGS) {
    if (typeof tag === 'string') {
      PIXIV_BLOCKED_TAGS_STRING.push(String(tag));
    } else if (Array.isArray(tag)) {
      PIXIV_BLOCKED_TAGS_REGEXP.push(new RegExp(tag[0], tag[1] || ''));
    }
  }

  logDebug('PIXIV_BLOCKED_TAGS_STRING = ', PIXIV_BLOCKED_TAGS_STRING);
  logDebug('PIXIV_BLOCKED_TAGS_REGEXP = ', PIXIV_BLOCKED_TAGS_REGEXP);
  const PIXIV_BLOCKED_TAGS_VALIDATED = PIXIV_BLOCKED_TAGS_STRING.length || PIXIV_BLOCKED_TAGS_REGEXP.length;

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

  let currentUrl = new URL(window.location.href, window.location.origin).href;
  const notify = (method, url) => {
    const newUrl = new URL(url || window.location.href, window.location.origin).href;
    if (currentUrl !== newUrl) {
      const event = new CustomEvent('detectnavigate');
      window.dispatchEvent(event);
      currentUrl = newUrl;
    }
  };

  if (window.onurlchange === null) {
    window.addEventListener('urlchange', event => {
      notify('urlchange', event.url);
    });
    logDebug('Using window.onurlchange.');
  } else {
    const oldMethods = {};
    ['pushState', 'replaceState'].forEach(method => {
      oldMethods[method] = history[method];
      history[method] = function (...args) {
        oldMethods[method].apply(this, args);
        notify(method, args[2]);
      };
    });

    window.addEventListener('popstate', event => {
      notify(event.type);
    });
    logDebug('Using window.onurlchange polyfill.');
  }

  /** MAIN STYLES **/

  const formatChildSelector = (parentSelector, childSelector) => {
    let child = childSelector;
    if (childSelector.startsWith('&')) {
      child = childSelector.substring(1).trimStart();
    }

    const formatted = [];
    const parents = parentSelector.split(', ');
    for (const parent of parents) {
      formatted.push(`${parent} ${child}`);
    }

    return formatted.join(', ');
  };

  const _formatSelectorsMultiViewControls = () => {
    const multiViews = CONFIG.SELECTORS_MULTI_VIEW.split(', ');
    const multiViewsControls = CONFIG.SELECTORS_MULTI_VIEW_CONTROLS.split(', ');

    const formatted = [];
    for (const parent of multiViews) {
      for (const child of multiViewsControls) {
        formatted.push(formatChildSelector(parent, child));
      }
    }

    return formatted;
  };

  const _SELECTORS_IMAGE_CONTROLS = CONFIG.SELECTORS_IMAGE_CONTROLS.split(', ');

  const _FILTERED_SELECTORS_IMAGE_CONTROLS = _SELECTORS_IMAGE_CONTROLS
    .filter(s => !['._layout-thumbnail', '.novel-right-contents'].includes(s))
    .join(', ');

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

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

  :is(.byWzRq, .hFAmSK, .gAyuNi, .cgYJXZ, .Yzjmx) .pixiv_utils_edit_bookmark {
    margin-top: -26px;
  }

  .pixiv_utils_edit_bookmark {
    color: rgb(245, 245, 245);
    background: rgba(0, 0, 0, 0.5);
    display: block;
    box-sizing: border-box;
    padding: 0px 8px;
    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;
    position: relative;
    z-index: 1;
  }

  :is(${CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS}) .pixiv_utils_edit_bookmark,
  :is(${_formatSelectorsMultiViewControls().join(', ')}) .pixiv_utils_edit_bookmark {
    font-size: 12px;
    height: 24px;
    line-height: 24px;
    margin-top: 5px;
    margin-right: 7px;
  }

  :is(._layout-thumbnail, .novel-right-contents, .imgoverlay) .pixiv_utils_edit_bookmark {
    position: absolute !important;
    right: calc(50% - 71px);
    bottom: 4px;
    z-index: 2;
  }

  .novel-right-contents .pixiv_utils_edit_bookmark {
    right: 50px;
  }

  .imgoverlay .pixiv_utils_edit_bookmark {
    right: 40px;
    bottom: 15px;
  }

  :not(#higher_specificity) :has(> .pixiv_utils_image_artist_container) {
    position: relative !important;
  }

  .pixiv_utils_image_artist_container {
    position: absolute;
    padding: 5px;
    bottom: 0;
    left: 0;
    max-width: calc(100% - 76px);
  }

  .pixiv_utils_image_artist {
    color: rgb(245, 245, 245);
    background: rgba(0, 0, 0, 0.5);
    box-sizing: border-box;
    padding: 0px 8px;
    border-radius: 10px;
    font-weight: bold;
    font-size: 14px;
    line-height: 20px;
    height: 20px;
    text-overflow: ellipsis;
    overflow: hidden;
    white-space: nowrap;
    float: left;
    width: 100%;
  }

  :is(.bXtqby, .eEVUIK):has(+ .pixiv_utils_toggle_bookmarked_container) {
    flex-grow: 1;
    justify-content: flex-end;
  }

  .pixiv_utils_toggle_bookmarked_container {
    text-align: center;
  }

  .pixiv_utils_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;
  }

  .pixiv_utils_toggle_bookmarked:hover {
    text-decoration: none;
  }

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

  ${_FILTERED_SELECTORS_IMAGE_CONTROLS} {
    display: flex;
    justify-content: flex-end;
  }

  [data-pixiv_utils_highlight] :not(.page-count):has(> img:not([src^="data"]))::after {
    box-shadow: inset 0 0 0 2px ${CONFIG.PIXIV_HIGHLIGHTED_COLOR};
    border-radius: 8px;
    content: '';
    display: block;
    width: 100%;
    height: 100%;
    position: absolute;
    top: 0;
  }

  /* expanded view's artist bottom bar */
  .eoaxji > div:has(a[href])[data-pixiv_utils_highlight] :not(.page-count):has(> img:not([src^="data"]))::after {
    border-radius: 4px;
  }

  /* user profile popup */
  .eyusRs > div[data-pixiv_utils_highlight] :not(.page-count):has(> img:not([src^="data"]))::after,
  /* mobile image */
  .works-item-illust[data-pixiv_utils_highlight] :not(.page-count):has(> img:not([src^="data"]))::after {
    border-radius: 0;
  }

  .eyusRs > div[data-pixiv_utils_highlight]:nth-child(1) :not(.page-count):has(> img:not([src^="data"]))::after {
    border-bottom-left-radius: 8px;
  }

  .eyusRs > div[data-pixiv_utils_highlight]:nth-child(3) :not(.page-count):has(> img:not([src^="data"]))::after {
    border-bottom-right-radius: 8px;
  }

  :not(#higher_specificity) :has(+ .pixiv_utils_blocked_image_container) {
    display: none !important;
  }

  .pixiv_utils_blocked_image {
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100%;
    color: rgb(92, 92, 92);
    min-width: 93px;
    aspect-ratio: 1 / 1;
  }

  .pixiv_utils_blocked_image svg {
    fill: currentcolor;
  }

  .ranking-item .pixiv_utils_blocked_image {
    max-width: 150px;
    margin: 0 auto;
    border: 1px solid rgb(242, 242, 242);
  }

  /* Pixiv's built-in tags mute. */
  .ranking-item.muted .work img {
    filter: brightness(50%);
  }

  .ranking-item.muted .muted-thumbnail .negative {
    position: relative;
    z-index: 1;
    color: rgb(92, 92, 92);
  }

  /* Only use black background on desktop layout. */
  body > div:not(#wrapper) .pixiv_utils_blocked_image,
  body > div:not(#wrapper) .ranking-item.muted .work img {
    background: rgb(0, 0, 0);
  }

  [data-pixiv_utils_blocked] :is(.series-title, .tag-container, .show-more-creator-works-button),
  [data-pixiv_utils_blocked] .pqkmS, /* desktop: show more creator works button */
  [data-pixiv_utils_blocked] ._illust-series-title-text {
    display: none !important;
  }

  [data-pixiv_utils_blocked] :is(${CONFIG.SELECTORS_IMAGE_TITLE}) {
    display: none !important;
  }

  [data-pixiv_utils_blocked] :is(${CONFIG.SELECTORS_IMAGE_ARTIST_AVATAR}) {
    display: none !important;
  }

  [data-pixiv_utils_blocked] :is(${CONFIG.SELECTORS_IMAGE_ARTIST_NAME}) {
    display: none !important;
  }

  [data-pixiv_utils_blocked] :is(${_SELECTORS_IMAGE_CONTROLS}) {
    display: none !important;
  }

  [data-pixiv_utils_blocked] .pixiv_utils_image_artist_container {
    max-width: calc(100% - 10px);
  }

  [data-pixiv_utils_blocked] .pixiv_utils_image_artist {
    background: none;
    padding: 0;
    width: 0;
  }

  [data-pixiv_utils_expanded_view_blocked] :is([role="presentation"], .work-main-image) :is(img, canvas) {
    filter: blur(32px);
  }

  [data-pixiv_utils_expanded_view_blocked] :is([role="presentation"], .work-main-image):hover :is(img, canvas) {
    filter: unset;
  }
  `;

  const SELECTORS_DATE_ORIGINAL = `:is(${CONFIG.SELECTORS_DATE}):not([data-pixiv_utils_duplicate_date])`;

  const mainDateStyle = /*css*/`
  ${SELECTORS_DATE_ORIGINAL} {
    display: none !important;
  }

  :is(${CONFIG.SELECTORS_DATE})[data-pixiv_utils_duplicate_date]:not([data-pixiv_utils_date_prefix]) {
    font-size: 14px !important;
    font-weight: bold !important;
    color: rgb(214, 214, 214) !important;
  }
  `;

  const BLOCKED_IMAGE_HTML = /*html*/`
  <div radius="4" class="pixiv_utils_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);
  }

  const uuidv4 = () => {
    return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, c =>
      (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16)
    );
  };

  const WAIT_FOR_POLL = 1000; // ms

  const waitForIntervals = {};

  const waitFor = (func, element = document) => {
    if (typeof func !== 'function') {
      return false;
    }

    return new Promise(resolve => {
      let interval = null;
      const find = () => {
        const result = func(element);
        if (result) {
          if (interval) {
            delete waitForIntervals[interval];
            clearInterval(interval);
          }
          return resolve(result);
        }
      };
      find();
      interval = setInterval(find, WAIT_FOR_POLL);
      waitForIntervals[interval] = { func, element, resolve };
    });
  };

  const initElementObserver = (element, callback, options = {}) => {
    if (!element || typeof callback !== 'function' || typeof options !== 'object' || !Object.keys(options).length) {
      return false;
    }

    // Skip if already observing.
    if (element.dataset.pixiv_utils_observing) {
      return false;
    }

    if (options.attributes &&
      (!options.attributeFilter || options.attributeFilter.includes('pixiv_utils_observing'))) {
      console.error('initElementObserver cannot be initiated without proper attributes filtering', element);
      return false;
    }

    // Mark as observing.
    element.dataset.pixiv_utils_observing = true;

    const MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;
    const observer = new MutationObserver((mutations, observer) => {
      callback.call(this, mutations, observer);
    });

    observer.observe(element, options);
    return observer;
  };

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

    const button = document.createElement('a');
    button.className = 'pixiv_utils_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 findArtworkUrl = element => {
    return element.querySelector('a[href*="artworks/"]');
  };

  const findIllustUrl = element => {
    return element.querySelector('a[href*="illust_id="]');
  };

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

  const findIllustImg = element => {
    return element.querySelector('img[src*="_p0"]');
  };

  const findItemData = (element, tryImg = false) => {
    const methods = [
      { func: findIllustImg, regex: /\/(\d+)_p0/, img: true },
      { func: findArtworkUrl, regex: /artworks\/(\d+)/ },
      { func: findIllustUrl, regex: /illust_id=(\d+)/ },
      { func: findNovelUrl, regex: /novel\/show\.php\?id=(\d+)/, novel: true }
    ];

    const result = {
      id: null,
      novel: false
    };

    for (const method of methods) {
      if (method.img && !tryImg) {
        continue;
      }

      const found = method.func(element);
      if (found) {
        let value = '';
        if (method.img) {
          value = found.src;
          result.img = found;
        } else {
          value = found.href;
          result.link = found;
        }

        const match = value.match(method.regex);
        if (match) {
          result.id = match[1];
          result.novel = Boolean(method.novel);
        }

        break;
      }
    }

    return result;
  };

  // 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 getImagePixivData = async element => {
    const data = {
      title: null,
      ai: null,
      tags: null
    };

    if (element.__vue__) {
      for (const key of ['item', 'illustDetails']) {
        const _data = element.__vue__._props?.[key];
        if (!_data) {
          continue;
        }

        if (key === 'item') {
          const awaited = await waitFor(() => !_data.notLoaded, element);
          if (!awaited) {
            return false;
          }
        }

        data.title = _data.title;
        data.ai = _data.ai_type === 2;
        data.tags = _data.tags;
      }
    } else {
      const reactFiberKey = Object.keys(element).find(k => k.startsWith('__reactFiber'));
      if (reactFiberKey) {
        const MAX_STEPS = 5;

        let step = 0;
        const traverseChild = obj => {
          if (!obj || !obj.memoizedProps) {
            return;
          }

          step++;
          const props = obj.memoizedProps;
          if (props.tags) {
            data.title = props.title;
            data.ai = props.aiType === 2;
            data.tags = props.tags;
          } else if (props.content?.thumbnails?.length) {
            const thumbnail = props.content.thumbnails[0];
            data.title = thumbnail.title;
            data.ai = thumbnail.aiType === 2;
            data.tags = thumbnail.tags;
          } else if (props.children) {
            let children = props.children;
            if (!Array.isArray(children) || typeof children !== 'object') {
              children = props.children.props?.children;
            }
            if (children) {
              if (!Array.isArray(children)) {
                children = [children];
              }
              for (const child of children) {
                if (child.props?.thumbnail) {
                  data.title = child.props.thumbnail.title;
                  data.ai = child.props.thumbnail.aiType === 2;
                  data.tags = child.props.thumbnail.tags;
                  break;
                }
              }
            }
          } else {
            for (const key of ['rawThumbnail', 'thumbnail', 'work']) {
              if (props[key]) {
                data.title = props[key].title;
                data.ai = props[key].aiType === 2;
                data.tags = props[key].tags;
                break;
              }
            }
          }

          if (data.tags === null && step < MAX_STEPS) {
            traverseChild(obj.child);
          }
        };

        traverseChild(element[reactFiberKey]);
      }
    }

    if (!data.tags) {
      data.tags = [];
    }

    // Re-map extended tags data.
    data.tags = data.tags.map(tag => typeof tag !== 'string' ? tag.name : tag);

    return data;
  };

  const setImageTitle = (element, options = {}) => {
    let title = '';

    if (options.pixivData.title) {
      title += options.pixivData.title;
    }

    if (options.pixivData.ai) {
      title += '\nAI-generated';
    }

    if (options.pixivData.tags.length) {
      title += `\n${options.pixivData.tags.join(', ')}`;
    }

    if (options.highlightHint) {
      title += `\nHighlighted by:\n${options.highlightHint}`;
    } else if (options.blockHint) {
      title += `\nBlocked by:\n${options.blockHint}`;
    }

    title = title.trim();

    if (title.length) {
      element.title = title.trim();
    }
  };

  const isImageHighlightedByData = data => {
    const highlightedTags = [];

    for (const tag of data.tags) {
      if (PIXIV_HIGHLIGHTED_TAGS_STRING.includes(tag) || PIXIV_HIGHLIGHTED_TAGS_REGEXP.some(t => t.test(tag))) {
        highlightedTags.push(tag);
      }
    }

    if (!highlightedTags.length) {
      return false;
    }

    let hint = '';
    const highlightedTagsStr = highlightedTags.join(', ');
    if (highlightedTagsStr) {
      hint = `Tags: ${highlightedTagsStr}`;
    }

    return { hint };
  };

  const doHighlightImage = (element, options = {}) => {
    // Skip if already highlighted.
    if (element.dataset.pixiv_utils_highlight) {
      return false;
    }

    const highlighted = isImageHighlightedByData(options.pixivData);
    if (!highlighted) {
      return false;
    }

    element.dataset.pixiv_utils_highlight = true;

    return highlighted;
  };

  const isImageBlockedByData = data => {
    const blockedAI = CONFIG.PIXIV_BLOCK_AI && data.ai;
    const blockedTags = [];

    for (const tag of data.tags) {
      if (PIXIV_BLOCKED_TAGS_STRING.includes(tag) || PIXIV_BLOCKED_TAGS_REGEXP.some(t => t.test(tag))) {
        blockedTags.push(tag);
      }
    }

    if (!blockedAI && !blockedTags.length) {
      return false;
    }

    let hint = '';
    if (CONFIG.PIXIV_BLOCK_AI && blockedAI) {
      hint = 'AI-generated';
    }

    const blockedTagsStr = blockedTags.join(', ');
    if (blockedTagsStr) {
      hint += `\nTags: ${blockedTagsStr}`;
    }

    hint = hint.trim();
    return { hint };
  };

  const setImageBlocked = (element, options = {}) => {
    // Skip if already blocked (check for element due to possibility of dynamic update).
    if (element.querySelector('.pixiv_utils_blocked_image_container')) {
      return false;
    }

    element.dataset.pixiv_utils_blocked = true;

    // For mobile, never remove blocked, as it does not behave well with Pixiv's in-place navigation.
    if (options.remove && !options.mobile) {
      element.style.display = 'none';
      return true;
    }

    const blockedThumb = document.createElement('a');
    blockedThumb.className = 'pixiv_utils_blocked_image_container';
    blockedThumb.href = options.link.href;
    blockedThumb.innerHTML = BLOCKED_IMAGE_HTML;

    options.link.after(blockedThumb);

    return true;
  };

  const doBlockImage = async (element, options = {}) => {
    const blocked = isImageBlockedByData(options.pixivData);
    if (!blocked) {
      return false;
    }

    let remove = CONFIG.PIXIV_REMOVE_BLOCKED;

    // Do not ever remove in sections known to have display issues.
    if (element.closest(SELECTORS_IMAGE_CONTAINER_SIMPLIFIED) ||
      element.matches(CONFIG.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE)) {
      remove = false;
    }

    const status = setImageBlocked(element, {
      mobile: element.matches(SELECTORS_IMAGE_MOBILE),
      remove,
      link: options.data.link
    });

    setImageTitle(element, {
      data: options.data,
      pixivData: options.pixivData,
      blockHint: blocked.hint
    });

    if (status) {
      logDebug(`Image blocked (${blocked.hint.replace('\n', ', ')})`, element);
    }

    return status;
  };

  const addImageArtist = async element => {
    let userId = null;
    let userName = null;

    if (element.__vue__) {
      const awaited = await waitFor(() => !element.__vue__._props?.item?.notLoaded, element);
      if (!awaited) {
        return false;
      }

      userId = element.__vue__._props.item.user_id;
      userName = element.__vue__._props.item.author_details.user_name;
    } else {
      const reactPropsKey = Object.keys(element).find(k => k.startsWith('__reactProps'));
      if (!reactPropsKey) {
        return false;
      }

      for (const key of ['rawThumbnail', 'thumbnail', 'work']) {
        if (element[reactPropsKey].children?.props?.[key]) {
          userId = element[reactPropsKey].children.props[key].userId;
          userName = element[reactPropsKey].children.props[key].userName;
          break;
        }
      }
    }

    if (!userId || !userName) {
      return false;
    }

    const div = document.createElement('div');
    div.className = 'pixiv_utils_image_artist_container';
    div.innerHTML = /*html*/`
      <a class="pixiv_utils_image_artist" href="https://www.pixiv.net/users/${userId}">${userName}</a>
    `;

    element.append(div);
    return true;
  };

  const doImage = async (element, options = {}) => {
    // Skip if invalid.
    if (!element.querySelector('a[href]')) {
      return false;
    }

    const data = findItemData(element);
    if (data.id === null) {
      return false;
    }

    if (CONFIG.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME && options.isHome && data.novel) {
      element.style.display = 'none';
      logDebug('Novel recommendation removed from home', element);
      return true;
    }

    // Process new entries in toggled bookmarked sections.
    if (element.closest('[data-pixiv_utils_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, unless forced.
    if (element.querySelector('.pixiv_utils_edit_bookmark') && !options.forced) {
      return false;
    }

    // Init MutationObserver for dynamic images.
    if (element.__vue__) {
      if (!element.dataset.pixiv_utils_last_tx) {
        initElementObserver(element, () => {
          const lastGrid = element.dataset.pixiv_utils_last_grid === 'true';
          if (element.dataset.tx !== element.dataset.pixiv_utils_last_tx ||
            element.classList.contains('grid') !== lastGrid) {
            options.forced = true;
            doImage(element, options);
          }
        }, {
          attributes: true,
          // Monitor class tag to also detect list/grid view change.
          attributeFilter: ['class', 'data-tx']
        });
      }
      element.dataset.pixiv_utils_last_tx = element.dataset.tx;
      element.dataset.pixiv_utils_last_grid = element.classList.contains('grid');
    }

    // Skip if already blocked, unless forced.
    if (element.dataset.pixiv_utils_blocked) {
      if (options.forced) {
        delete element.dataset.pixiv_utils_blocked;
        const blockedThumb = element.querySelector('.pixiv_utils_blocked_image_container');
        if (blockedThumb) {
          blockedThumb.remove();
        }
      } else {
        return false;
      }
    }

    // Reset other statuses if forced.
    if (options.forced) {
      delete element.title;
      delete element.dataset.pixiv_utils_highlight;
    }

    const pixivData = await getImagePixivData(element);

    // Only block images if not in own profile.
    if (PIXIV_BLOCKED_TAGS_VALIDATED && !options.isOwnProfile) {
      const blocked = await doBlockImage(element, { data, pixivData });
      if (blocked) {
        return true;
      }
    }

    let highlighted = null;
    if (PIXIV_HIGHLIGHTED_TAGS_VALIDATED) {
      highlighted = await doHighlightImage(element, { data, pixivData });
    }

    setImageTitle(element, {
      data,
      pixivData,
      highlightHint: highlighted?.hint
    });

    // Exit early if in own profile, and not in bookmarks tab.
    if (options.isOwnProfile && currentUrl.indexOf('/bookmarks') === -1) {
      return false;
    }

    // Exit early if in sections where images won't have control buttons.
    if (element.closest(SELECTORS_IMAGE_CONTAINER_SIMPLIFIED)) {
      return false;
    }

    const oldImageArtist = element.querySelector('.pixiv_utils_image_artist_container');
    if (oldImageArtist) {
      oldImageArtist.remove();
    }

    let imageControls = null;
    if (data.novel) {
      imageControls = element.querySelector(CONFIG.SELECTORS_IMAGE_CONTROLS);
    } else {
      // If it's not a novel, assume image controls may be delayed due to still being generated.
      imageControls = await waitFor(() => {
        return element.querySelector(CONFIG.SELECTORS_IMAGE_CONTROLS);
      }, element);
    }

    if (!imageControls) {
      return false;
    }

    const artistTag = element.querySelector('a[href*="users/"]');
    let hasVisibleArtistTag = Boolean(artistTag);
    if (hasVisibleArtistTag && element.offsetParent !== null) {
      // If the image itself is visible, but its built-in artist tag is not.
      hasVisibleArtistTag = artistTag.offsetParent !== null;
    }

    // Add artist tag if necessary.
    if (!hasVisibleArtistTag &&
      !element.closest('.user-badge .works-horizontal-list') && // never in mobile expanded view's artist bottom bar
      (currentUrl.indexOf('users/') === -1 || // never in artist page (except bookmarks tab)
      (currentUrl.indexOf('users/') !== -1 && currentUrl.indexOf('/bookmarks') !== -1))) {
      await addImageArtist(element);
    }

    const oldEditBookmarkButton = imageControls.querySelector('.pixiv_utils_edit_bookmark_container');
    if (oldEditBookmarkButton) {
      oldEditBookmarkButton.remove();
    }

    imageControls.prepend(editBookmarkButton(data.id, data.novel));
    return true;
  };

  const doBlockMultiView = async (element, options = {}) => {
    const blocked = isImageBlockedByData(options.pixivData);
    if (!blocked) {
      return false;
    }

    // For multi view artwork, always hide the whole entry instead.
    element.parentNode.style.display = 'none';
    logDebug(`Multi view entry removed (${blocked.hint})`, element);
    return true;
  };

  const doMultiView = async (element, options = {}) => {
    const data = findItemData(element);
    if (data.id === null) {
      return false;
    }

    const pixivDataSource = element.querySelector('div[data-ga4-label="thumbnail_link"]');
    if (pixivDataSource) {
      const pixivData = await getImagePixivData(pixivDataSource);

      if (PIXIV_BLOCKED_TAGS_VALIDATED) {
        const blocked = await doBlockMultiView(element, { pixivData });
        if (blocked) {
          return true;
        }
      }

      setImageTitle(element, { data, pixivData });
    }

    if (CONFIG.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME && options.isHome && data.novel) {
      element.parentNode.style.display = 'none';
      logDebug('Novel recommendation removed from home', element);
      return true;
    }

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

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

    multiViewControls.lastChild.before(editBookmarkButton(data.id, data.novel));
    return true;
  };

  const doBlockExpandedView = async (element, options = {}) => {
    // Reset blocked status if necessary.
    delete element.dataset.pixiv_utils_expanded_view_blocked;

    const blocked = isImageBlockedByData(options.pixivData);
    if (!blocked) {
      return false;
    }

    element.dataset.pixiv_utils_expanded_view_blocked = true;

    logDebug(`Expanded view blocked (${blocked.hint})`, element);
    return true;
  };

  const doExpandedViewControls = async (element, options = {}) => {
    const image = element.closest(CONFIG.SELECTORS_EXPANDED_VIEW_IMAGE);
    if (!image) {
      return false;
    }

    const pixivData = await getImagePixivData(image);
    if (pixivData && PIXIV_BLOCKED_TAGS_VALIDATED) {
      await doBlockExpandedView(image, { pixivData });
    }

    // Init MutationObserver for dynamic expanded view.
    if (image.__vue__) {
      const target = image.querySelector('.work-main-image');
      if (!image.dataset.pixiv_utils_last_id) {
        initElementObserver(target, mutations => {
          const data = findItemData(image, true);
          if (data.id !== image.dataset.pixiv_utils_last_id) {
            options.forced = true;
            doExpandedViewControls(image, options);
          }
        }, {
          subtree: true,
          childList: true,
          attributes: true,
          attributeFilter: ['href', 'src']
        });
      }
      const data = findItemData(image, true);
      image.dataset.pixiv_utils_last_id = data.id;
    }

    // Skip if edit bookmark button already inserted, unless forced.
    if (element.querySelector('.pixiv_utils_edit_bookmark') && !options.forced) {
      return false;
    }

    // Re-attempt to convert date.
    if (CONFIG.DATE_CONVERSION) {
      const dates = image.querySelectorAll(SELECTORS_DATE_ORIGINAL);
      for (const date of dates) {
        convertDate(date);
      }
    }

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

      // Re-process expanded view's artist bottom bar.
      const images = document.querySelectorAll(CONFIG.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE);
      for (const image of images) {
        await doImage(image, { forced: true });
      }

      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.PIXIV_REMOVE_BLOCKED || CONFIG.UTAGS_REMOVE_BLOCKED) {
      images = images.filter(image => !image.dataset.pixiv_utils_blocked);
    }

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

    GM_setValue('PREF_TOGGLE_BOOKMARKED_MODE', mode);

    toggling = false;

    return true;
  };

  const doToggleBookmarkedSection = async (element, sectionConfig) => {
    // Skip if this config has a sanity check function, and it passes.
    if (typeof sectionConfig.sanityCheck === 'function' && sectionConfig.sanityCheck()) {
      return false;
    }

    const imagesContainer = element.querySelector(sectionConfig.selectorImagesContainer);
    if (!imagesContainer) {
      return false;
    }

    // Skip if already processed.
    if (element.dataset.pixiv_utils_toggle_bookmarked_section) {
      if (element.dataset.pixiv_utils_toggle_bookmarked_section ===
        imagesContainer.dataset.pixiv_utils_toggle_bookmarked_section) {
        return false;
      }
      logDebug('Refreshing toggle bookmarked section due to images container update', element);
    }

    const header = element.querySelector(sectionConfig.selectorHeader);
    if (!header) {
      return false;
    }

    // Mark as processed.
    const uuid = element.dataset.pixiv_utils_toggle_bookmarked_section || uuidv4();
    element.dataset.pixiv_utils_toggle_bookmarked_section =
      imagesContainer.dataset.pixiv_utils_toggle_bookmarked_section = uuid;

    // Clear old button if it's being refreshed.
    const oldButtonContainer = element.querySelector('.pixiv_utils_toggle_bookmarked_container');
    if (oldButtonContainer) {
      oldButtonContainer.remove();
    }

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

    const button = document.createElement('a');
    button.className = 'pixiv_utils_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 = async element => {
    let image = element.closest(CONFIG.SELECTORS_IMAGE);

    let mobile = false;
    if (image) {
      mobile = image.matches(SELECTORS_IMAGE_MOBILE);
    } else {
      // For mobile images, re-attempt query with some patience.
      image = element.closest(SELECTORS_IMAGE_MOBILE);
      if (image) {
        mobile = true;
        const awaited = await waitFor(() => image.querySelector('.thumb:not([src^="data"])'), image);
        if (!awaited) {
          return false;
        }
      }
    }

    const utag = element.dataset.utags_tag;

    if (image) {
      const data = findItemData(image);
      if (data.id === null) {
        return false;
      }

      const pixivData = await getImagePixivData(image);

      const status = setImageBlocked(image, {
        mobile,
        remove: CONFIG.UTAGS_REMOVE_BLOCKED,
        link: data.link
      });

      setImageTitle(image, {
        data,
        pixivData,
        blockHint: `UTag: ${utag}`
      });

      if (status) {
        logDebug(`Image blocked (UTag: ${utag})`, image);
      }

      return status;
    }

    const multiView = element.closest(CONFIG.SELECTORS_MULTI_VIEW);
    if (multiView) {
      // For multi view artwork, always hide the whole entry instead.
      multiView.parentNode.style.display = 'none';
      logDebug(`Multi view entry removed (UTag: ${utag})`, multiView);
      return true;
    }

    const recommendedUserContainer = element.closest(CONFIG.SELECTORS_RECOMMENDED_USER_CONTAINER);
    if (recommendedUserContainer) {
      recommendedUserContainer.style.display = 'none';
      logDebug(`Recommended user removed (UTag: ${utag})`, recommendedUserContainer);
      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.classList.add('disabled');
        followButton.disabled = true;
        logDebug(`Follow button disabled (UTag: ${utag})`, followButtonContainer);
        // Return early since there will only be one follow button per container.
        return true;
      }
    }

    return false;
  };

  let isHome = false;
  let isOwnProfile = false;

  const determinePageType = () => {
    isHome = Boolean(document.querySelector(CONFIG.SELECTORS_HOME));
    isOwnProfile = Boolean(document.querySelector(CONFIG.SELECTORS_OWN_PROFILE));
    logDebug(`isHome: ${isHome}, isOwnProfile: ${isOwnProfile}`);
  };

  window.addEventListener('detectnavigate', event => {
    const intervals = Object.keys(waitForIntervals);
    for (const interval of intervals) {
      clearInterval(interval);
      waitForIntervals[interval].resolve();
      delete waitForIntervals[interval];
    }
    if (intervals.length > 0) {
      logDebug(`Cleared ${intervals.length} pending waitFor interval(s).`);
    }

    // Reset page type.
    isHome = isOwnProfile = false;
  });

  /** SENTINEL */

  waitPageLoaded().then(() => {
    // Immediately attempt to determine page type.
    determinePageType();

    sentinel.on(CONFIG.SELECTORS_HOME, () => {
      isHome = true;
      logDebug(`isHome: ${isHome}`);
    });

    sentinel.on(CONFIG.SELECTORS_OWN_PROFILE, () => {
      isOwnProfile = true;
      logDebug(`isOwnProfile: ${isOwnProfile}`);
    });

    // Expanded View Controls
    sentinel.on(CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS, element => {
      doExpandedViewControls(element);
    });

    // Images
    sentinel.on([
      CONFIG.SELECTORS_IMAGE,
      CONFIG.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE
    ], element => {
      doImage(element, { isHome, isOwnProfile });
    });

    // Multi View Entries
    sentinel.on(CONFIG.SELECTORS_MULTI_VIEW, element => {
      doMultiView(element, { isHome });
    });

    // Toggle Bookmarked Sections
    for (const sectionConfig of CONFIG.SECTIONS_TOGGLE_BOOKMARKED) {
      let configValid = true;
      for (const key of ['selectorParent', 'selectorHeader', 'selectorImagesContainer']) {
        if (!sectionConfig[key] || !isSelectorValid(sectionConfig[key])) {
          console.error(`SECTIONS_TOGGLE_BOOKMARKED contains invalid ${key} =`, sectionConfig[key]);
          configValid = false;
          break;
        }
      }

      if (!configValid) {
        continue;
      }

      sentinel.on(sectionConfig.selectorParent, element => {
        doToggleBookmarkedSection(element, sectionConfig);
      });

      const formattedSelector = formatChildSelector(
        sectionConfig.selectorParent,
        sectionConfig.selectorImagesContainer
      );

      sentinel.on(formattedSelector, element => {
        const parent = element.closest(sectionConfig.selectorParent);
        if (parent && !element.dataset.pixiv_utils_toggle_bookmarked_section) {
          doToggleBookmarkedSection(parent, sectionConfig);
        }
      });
    }

    // Dates
    if (CONFIG.DATE_CONVERSION) {
      sentinel.on([
        `:has(> :is(${CONFIG.SELECTORS_DATE})):not(:has(> [data-pixiv_utils_duplicate_date]))`,
        `.reupload-info:has(${SELECTORS_DATE_ORIGINAL})`
      ], element => {
        const date = element.querySelector(SELECTORS_DATE_ORIGINAL);
        if (date) {
          convertDate(date);
        }
      });
    }

    // UTags Integration
    if (CONFIG.UTAGS_INTEGRATION) {
      sentinel.on(SELECTORS_UTAGS, element => {
        doUtags(element);
      });
    }

    if (CONFIG.MODE !== 'PROD') {
      setInterval(() => {
        const intervals = Object.keys(waitForIntervals);
        if (intervals.length > 0) {
          // Debug first pending interval.
          logDebug('waitFor', waitForIntervals[intervals[0]].element);
        }
      }, 2500);
    }
  });

  /** KEYBINDS **/

  if (CONFIG.ENABLE_KEYBINDS) {
    const selectors = {
      editBookmark: CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS
        .split(', ').map(s => `${s} .pixiv_utils_edit_bookmark`).join(', ')
    };

    const 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(selectors.editBookmark);
          return processKeyEvent('bookmarkEdit', element);
        }
      }
    });

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