E-Hentai - UX Tweaks

Numerous features to enrich your browsing experience

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         E-Hentai - UX Tweaks
// @namespace    brazenvoid
// @version      2.2.8
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Numerous features to enrich your browsing experience
// @match        https://e-hentai.org/*
// @match        https://exhentai.org/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js
// @require      https://update.greasyfork.org/scripts/375557/1875051/Brazen%20Framework%20-%20Utilities.js
// @require      https://update.greasyfork.org/scripts/416104/1875150/Brazen%20Framework%20-%20View%20Layer.js
// @require      https://update.greasyfork.org/scripts/418665/1875786/Brazen%20Framework%20-%20Configuration%20Manager.js
// @require      https://update.greasyfork.org/scripts/429587/1847139/Brazen%20Framework%20-%20Item%20Attributes%20Resolver.js
// @require      https://update.greasyfork.org/scripts/416105/1875149/Brazen%20Framework%20-%20Framework.js
// @grant        GM_addStyle
// @grant        GM_download
// @run-at       document-end
// ==/UserScript==

GM_addStyle(
  String.raw`#bv-ui{font-size:1rem;min-width:310px;width:310px}#bv-ui textarea.bv-input,#bv-ui .bv-textarea-group>label.bv-label,#bv-ui .bv-stat-group :is(.bv-label,.bv-stat-label){font-family:var(--bv-font-family,ui-sans-serif,system-ui,sans-serif);font-size:1rem;font-weight:normal}#bv-ui textarea.bv-input{line-height:1.35}.adult-tag::after{content:'\2713';position:absolute;top:-8px;right:0;font-size:10px;color:aquamarine}.disliked-tag{background-color:lightcoral !important;color:white !important}.disliked-tag:hover{background-color:indianred !important}.disliked-tag > a{color:white !important}.disliked-tag.favourite-tag{background-color:orange !important}.disliked-tag.favourite-tag:hover{background-color:darkorange !important}.favourite-tag{background-color:mediumseagreen !important;color:white !important}.favourite-tag:hover{background-color:forestgreen !important}.favourite-tag > a{color:white !important}.underage-tag::after{content:'\2717';position:absolute;top:-8px;right:0;font-size:10px;color:red}.unknown-age-tag::after{content:'\003F';position:absolute;top:-8px;right:0;font-size:10px;color:yellow}`)

const ITEM_RATED_BLUE = 'ratedBlue'
const ITEM_RATED_GREEN = 'ratedGreen'
const ITEM_RATED_RED = 'ratedRed'
const ITEM_TAGS = 'tags'
const ITEM_WATCHED = 'watched'

const FILTER_RATED_VIDEOS = 'hide-rated-galleries'
const FILTER_UNDERAGE_CHARACTERS_OPTION = 'enable-underage-females-filter'
const FILTER_WATCHED_FROM_SEARCH = 'hide-watched-galleries'

const STYLE_GALLERY_HIGHLIGHT = 'gallery-highlight'

const UI_DEFAULTS_PAGE_RANGE = 'page-range'
const UI_DEFAULTS_PAGE_RANGE_ENABLE = 'enable-page-range-filter'
const UI_DEFAULTS_RATING = 'rating'
const UI_DEFAULTS_RATING_ENABLE = 'enable-rating-filter'
const UI_DEFAULTS_TAGS = 'tags'
const UI_DEFAULTS_TAGS_ENABLE = 'enable-default-tags'

const UI_OPEN_GALLERIES_AUTO_NEXT = 'search-auto-next'
const UI_OPEN_GALLERY_PAGES_AUTO_NEXT = 'gallery-auto-next'
const UI_OPEN_GALLERY_PAGES_THROTTLING = 'throttling-bypass'
const UI_OPEN_GALLERY_PAGES_DOWNLOAD = 'download-the-image'

const UI_EMBED_TORRENTS = 'embed-torrent-downloads'
const UI_VISITED_HIGHLIGHT = 'highlight-visited'
const UI_GALLERY_HIGHLIGHTS = 'gallery-highlights'
const UI_GALLERY_HIGHLIGHTS_COLOUR = 'highlight-colour'

const UI_TAG_HIGHLIGHTS_ADULT_CHARACTER = 'adult-characters'
const UI_TAG_HIGHLIGHTS_DISLIKED = 'disliked-tags'
const UI_TAG_HIGHLIGHTS_FAVOURITE = 'favourite-tags'
const UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER = 'underage-characters'
const UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER = 'unknown-age-characters'

const LEGACY_CONFIG_KEY_MAP = [
  ['Hide Rated Galleries', FILTER_RATED_VIDEOS],
  ['Enable Underage Females Filter', FILTER_UNDERAGE_CHARACTERS_OPTION],
  ['Hide Watched Galleries', FILTER_WATCHED_FROM_SEARCH],
  ['Page Range', UI_DEFAULTS_PAGE_RANGE],
  ['Enable Page Range Filter', UI_DEFAULTS_PAGE_RANGE_ENABLE],
  ['Rating', UI_DEFAULTS_RATING],
  ['Enable Rating Filter', UI_DEFAULTS_RATING_ENABLE],
  ['Tags', UI_DEFAULTS_TAGS],
  ['Enable Default Tags', UI_DEFAULTS_TAGS_ENABLE],
  ['Search Auto Next', UI_OPEN_GALLERIES_AUTO_NEXT],
  ['Gallery Auto Next', UI_OPEN_GALLERY_PAGES_AUTO_NEXT],
  ['Throttling Bypass', UI_OPEN_GALLERY_PAGES_THROTTLING],
  ['Download The Image', UI_OPEN_GALLERY_PAGES_DOWNLOAD],
  ['Embed Torrent Downloads', UI_EMBED_TORRENTS],
  ['Highlight Visited', UI_VISITED_HIGHLIGHT],
  ['Gallery Highlights', UI_GALLERY_HIGHLIGHTS],
  ['Highlight Colour', UI_GALLERY_HIGHLIGHTS_COLOUR],
  ['Adult Characters', UI_TAG_HIGHLIGHTS_ADULT_CHARACTER],
  ['Disliked Tags', UI_TAG_HIGHLIGHTS_DISLIKED],
  ['Favourite Tags', UI_TAG_HIGHLIGHTS_FAVOURITE],
  ['Underage Characters', UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER],
  ['Unknown Age Characters', UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER],
]

/**
 * @param {string[]} rules
 * @return {string[][]}
 */
function optimizeTagRulesAsTags(rules)
{
  let orTags, iteratedRuleset
  let optimizedRuleset = []

  let expandRuleset = (ruleset, tags) => {
    let grownRuleset = []
    for (let tag of tags) {
      let cleanedTag = tag.trim()
      for (let rule of ruleset) {
        grownRuleset.push([...rule, cleanedTag])
      }
    }
    return grownRuleset
  }

  let growRuleset = (ruleset, tagToAdd) => {
    if (ruleset.length) {
      tagToAdd = tagToAdd.trim()
      for (let rule of ruleset) {
        rule.push(tagToAdd)
      }
    } else {
      let tags = typeof tagToAdd === 'string' ? [tagToAdd] : tagToAdd
      for (let tag of tags) {
        ruleset.push([tag.trim()])
      }
    }
  }

  for (let rule of rules) {
    iteratedRuleset = []
    for (let andTag of rule.split(' // ')[0].split('&')) {
      orTags = andTag.split('|')
      if (orTags.length === 1) {
        growRuleset(iteratedRuleset, andTag)
      } else if (iteratedRuleset.length) {
        iteratedRuleset = expandRuleset(iteratedRuleset, orTags)
      } else {
        growRuleset(iteratedRuleset, orTags)
      }
    }
    optimizedRuleset = optimizedRuleset.concat(iteratedRuleset)
  }

  return optimizedRuleset.sort((a, b) => a.length - b.length)
}

class EHentaiSearchAndUITweaks extends BrazenFramework
{
constructor()
{
  super({
    downloadsDelay: 2,
    isUserLoggedIn: false,
    itemDeepAnalysisSelector: 'div.gm',
    itemLinkSelector: {
      extended: '.gl2e > div > a',
      compact: '.gl3c.glname > a',
      minimal: '.gl3m.glname > a',
      thumbnail: '.gl3t > a',
    },
    itemListSelectors: {
      extended: '.itg.glte > tbody',
      compact: '.itg.gltc > tbody',
      minimal: '.itg.gltm > tbody',
      thumbnail: '.itg.gld',
    },
    itemNameSelector: {
      extended: '.gl4e.glname > div.glink',
      compact: '.gl3c.glname > a > div.glink',
      minimal: '.gl3m.glname > a > div.glink',
      thumbnail: '.gl4t.glname',
    },
    itemSelectors: {
      extended: 'tr',
      compact: 'tr',
      minimal: 'tr',
      thumbnail: 'div.gl1t',
    },
    itemSelectionMethod: 'children',
    requestDelay: 0,
    scriptPrefix: 'e-hentai-ux-',
    tagSelectorGenerator: (tag) => {
      tag = tag.trim()
      if (this.isPage('gallery')) {
        let tagAttribute = tag.replaceAll(' ', '_')
        return 'div[id="td_' + tagAttribute + '"]'
      }
      return 'div.gt[title="' + tag + '"], div.gtl[title="' + tag + '"]'
    },
  })

  this.definePages({
    smallWindow: () => $('.stuffbox').length > 0,
    search: {
      detect: () => $('#f_search').length > 0,
      layout: () => {
        if ($('table.itg.glte').length > 0) {
          return 'extended'
        }
        if ($('table.itg.gltm').length > 0) {
          return 'minimal'
        }
        if ($('table.itg.gltc').length > 0) {
          return 'compact'
        }
        if ($('div.itg.gld').length > 0) {
          return 'thumbnail'
        }
        return null
      },
    },
    tagSearch: () => location.pathname.startsWith('/tag'),
    uploaderSearch: () => location.pathname.startsWith('/uploader'),
    watched: () => document.querySelectorAll('.ido > div > p.ip')?.length > 0,
    gallery: () => $('#gdt').length > 0,
    image: () => location.pathname.startsWith('/s/'),
  })

  this.setCompliancePages(['search'])

  this._onValidateInit = () => !this.isPage('smallWindow')

  this._setupFeatures()
  this._setupFilters()
  this._onBeforeFullInit.unshift(() => {
    this._migrateLegacyConfigKeys()
    // Phase 0 resolves layout-map itemLinkSelector on _config; the Item Attributes Resolver
    // still holds the raw map from construct time — sync so deep fetch can find hrefs.
    if (this._config.itemLinkSelector !== undefined) {
      this._itemAttributesResolver._itemLinkSelector = this._config.itemLinkSelector
    }
    this._registerItemTagAttribute()
  })
  this._onBeforeFullInit.push(() => {
    this._setupUI()
    this._setupEvents()
  })
}

/**
 * @private
 */
_migrateLegacyConfigKeys()
{
  let settingsKey = this._config.scriptPrefix + 'settings'
  let storedStore = localStorage.getItem(settingsKey)
  if (!storedStore || storedStore === '') {
    return
  }

  let settings
  try {
    settings = JSON.parse(storedStore)
  } catch {
    return
  }
  if (!settings || typeof settings !== 'object') {
    return
  }

  let changed = false
  for (let [oldKey, newKey] of LEGACY_CONFIG_KEY_MAP) {
    if (settings[oldKey] !== undefined && settings[newKey] === undefined) {
      settings[newKey] = settings[oldKey]
      delete settings[oldKey]
      changed = true
    }
  }

  if (changed) {
    localStorage.setItem(settingsKey, JSON.stringify(settings))
  }
}

/**
 * Register tag attribute after page/layout detection.
 * Thumbnail and minimal search layouts have no on-page tag chips — deep-fetch gallery HTML.
 * @private
 */
_registerItemTagAttribute()
{
  let searchLayout = this.getLayout('search')
  let deepAttribute = searchLayout === 'thumbnail' || searchLayout === 'minimal'
  this._addItemTagAttribute(
      ITEM_TAGS,
      deepAttribute,
      false,
      (item) => this._gatherItemTags(item))
}

/**
 * @param {string} configKey
 * @return {string[][]}
 * @private
 */
_getTagOnlyRulesFromField(configKey)
{
  let field = this._configurationManager.getField(configKey)
  if (!field?.value?.length) {
    return []
  }
  let rules = field.value
  if (typeof rules === 'string') {
    rules = rules.split('\n').map((line) => line.trim()).filter(Boolean)
  }
  if (field.formatter) {
    rules = field.formatter(rules)
  }
  return optimizeTagRulesAsTags(rules)
}

/**
 * @return {string}
 * @private
 */
_resolveDownloadFolderFromTitle()
{
  return ((f) => (f.includes('|') ? (f.match(/^(\s*\[[^\]]+\])/)?.[1] || '') + f.split('|')[1] : f))(document.title.replace(' --- Porn', ''))
}

/**
 * @param {ItemTagHighlightsConfiguration} config
 * @protected
 */
_ageFilterHighlighter(config)
{
  this.registerHighlightStyleClass(config.styleClass)

  this._configurationManager.addTagRulesetField(config.configKey, true).
      setRows(config.rows ?? 5).
      setHelpText(config.helpText).
      setFormatter((rules) => this._morphCharacterNamesToTags(rules)).
      setSortRules(true)

  let highlightsHandler = (section) => this._paintAgeTagHighlights(section, config.configKey, config.styleClass, config.removeClasses)
  this._onItemShow.push((item) => highlightsHandler(item))
}

/**
 * Paint favourite/disliked tag-ruleset highlights (CSS selectors in field.optimized).
 * @param {JQuery} section
 * @param {string} configKey
 * @param {string} styleClass
 * @param {string} [removeClasses]
 * @private
 */
_paintTagRulesetHighlights(section, configKey, styleClass, removeClasses)
{
  let optimizedRuleset = this._configurationManager.getField(configKey)?.optimized
  if (!optimizedRuleset) {
    return
  }

  let ruleApplies, subjectTags
  for (let rule of optimizedRuleset) {
    ruleApplies = true
    subjectTags = section.find(rule.join(', '))

    for (let tagSelector of rule) {
      if (section.find(tagSelector).length === 0) {
        ruleApplies = false
        break
      }
    }
    if (ruleApplies) {
      subjectTags.addClass(styleClass)
      if (removeClasses !== undefined) {
        subjectTags.removeClass(removeClasses)
      }
    } else {
      subjectTags.removeClass(styleClass)
    }
  }
}

/**
 * Paint age character/parody pair highlights.
 * @param {JQuery} section
 * @param {string} configKey
 * @param {string} styleClass
 * @param {string} [removeClasses]
 * @private
 */
_paintAgeTagHighlights(section, configKey, styleClass, removeClasses)
{
  let optimized = this._configurationManager.getField(configKey)?.optimized
  if (!optimized) {
    return
  }

  let characterTag, characterTagExists, parodyTagExists
  for (let rule of optimized) {
    characterTag = section.find(rule[0])
    characterTagExists = characterTag.length === 1
    parodyTagExists = section.find(rule[1]).length === 1

    if (characterTagExists && parodyTagExists) {
      characterTag.addClass(styleClass)
      if (removeClasses !== undefined) {
        characterTag.removeClass(removeClasses)
      }
    }
  }
}

/**
 * Gallery `#taglist` chip highlights (wired after page detection — not in constructor).
 * @param {JQuery} taglist
 * @private
 */
_paintGalleryPageTagHighlights(taglist)
{
  if (!taglist?.length) {
    return
  }
  this._paintTagRulesetHighlights(taglist, UI_TAG_HIGHLIGHTS_FAVOURITE, 'favourite-tag')
  this._paintTagRulesetHighlights(taglist, UI_TAG_HIGHLIGHTS_DISLIKED, 'disliked-tag')
  this._paintAgeTagHighlights(taglist, UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER, 'underage-tag', 'favourite-tag disliked-tag')
  this._paintAgeTagHighlights(taglist, UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER, 'unknown-age-tag')
  this._paintAgeTagHighlights(taglist, UI_TAG_HIGHLIGHTS_ADULT_CHARACTER, 'adult-tag')
}

/**
 * Append synthetic `type:…` when real tags are already on the item (shallow path, or after deep load).
 * Do not seed a type-only bag while deep fetch is pending — that races with deep overwrite.
 * @param {JQuery} item
 * @private
 */
_appendGalleryTypeTag(item)
{
  let bag = item[0]?.scriptAttributes
  if (!bag || !Array.isArray(bag.tags)) {
    return
  }

  let galleryType = item.find('.cn,.cs').first().text()
  let galleryTypeMap = new Map([
    ['Artist CG', 'acg'],
    ['Asian Porn', 'aporn'],
    ['Cosplay', 'cosplay'],
    ['Doujinshi', 'dj'],
    ['Game CG', 'gcg'],
    ['Image Set', 'img'],
    ['Manga', 'manga'],
    ['Misc', 'misc'],
    ['Non-H', 'nonh'],
    ['Western', 'west'],
  ])
  let mapped = galleryTypeMap.get(galleryType)
  if (!mapped) {
    return
  }

  let typeTag = 'type:' + mapped
  if (!bag.tags.includes(typeTag)) {
    bag.tags.push(typeTag)
  }
}

/**
 * @param {string} tag
 * @return {string}
 * @private
 */
_formatTag(tag)
{
  if (tag.includes(':') && !tag.includes('"') && (tag.includes(' ') || tag.includes('+'))) {
    tag = tag.replace(':', ':"') + '"'
  }
  return tag
}

/**
 *
 * @param {JQuery} item
 * @return {string[]}
 * @private
 */
_gatherItemTags(item)
{
  let tags = []
  item.find('.gt,.gtl').each((_i, e) => {
    let element = $(e)
    let title = element.attr('title')
    if (title) {
      tags.push(title)
      return
    }
    let tagID = element.find('a').attr('id') ?? element.attr('id')
    if (!tagID) {
      return
    }
    if (tagID.startsWith('ta_')) {
      tagID = tagID.replace('ta_', '')
    }
    if (tagID.startsWith('td_')) {
      tagID = tagID.replace('td_', '')
    }
    tags.push(tagID.replaceAll('_', ' '))
  })
  return tags
}

/**
 * @param {{}} range
 * @param {URLSearchParams} queryParams
 * @private
 */
_handleDefaultPageRangeFilter(range, queryParams)
{
  if (range.minimum > 0) {
    queryParams.set('f_spf', range.minimum)
  }
  if (range.maximum > 0) {
    queryParams.set('f_spt', range.maximum)
  }
}

/**
 * @param {string} rating
 * @param {URLSearchParams} queryParams
 * @private
 */
_handleDefaultRatingsFilter(rating, queryParams)
{
  queryParams.set('f_srdd', rating)
}

/**
 * @param {string[]} tags
 * @param {URLSearchParams} queryParams
 * @private
 */
_handleDefaultTags(tags, queryParams)
{
  let existingTags = queryParams.get('f_search')
  let updatedTags = existingTags
  let include = true

  for (let tag of tags) {
    if (existingTags.includes(tag)) {
      include = false
      break
    } else {
      updatedTags += '+' + this._formatTag(tag)
    }
  }

  if (include) {
    queryParams.set('f_search', updatedTags)
  }
}

/**
 * @private
 */
_handleDefaults()
{
  let queryParams = new URLSearchParams(location.search)
  let existingParams = queryParams.toString()

  if (!queryParams.has('next') &&
      (this._getConfig(UI_DEFAULTS_PAGE_RANGE_ENABLE) || this._getConfig(UI_DEFAULTS_RATING_ENABLE) || this._getConfig(UI_DEFAULTS_TAGS_ENABLE))) {

    if (!queryParams.has('f_search')) {

      let existingTag = ''
      let urlSegments = location.pathname.split('/')

      if (this.isPage('tagSearch')) {
        existingTag = urlSegments.pop().trim()
      } else if (this.isPage('uploaderSearch')) {
        existingTag = 'uploader:' + urlSegments.pop().trim()
      }
      queryParams.set('f_search', existingTag.length ? this._formatTag(existingTag) : '')
    }

    if (!queryParams.has('advsearch')) {
      queryParams.set('advsearch', '1')
    }

    let validatePageRange = (range, defaultValidator) => defaultValidator(range) && !queryParams.has('f_spf') &&
        !queryParams.has('f_spt')

    this._performTogglableComplexOperation(UI_DEFAULTS_PAGE_RANGE_ENABLE, UI_DEFAULTS_PAGE_RANGE, validatePageRange,
        (range) => {
          this._handleDefaultPageRangeFilter(range, queryParams)
        })

    let validateRatingFilter = (range, defaultValidator) => defaultValidator(range) && !queryParams.has('f_srdd')

    this._performTogglableComplexOperation(UI_DEFAULTS_RATING_ENABLE, UI_DEFAULTS_RATING, validateRatingFilter,
        (rating) => {
          this._handleDefaultRatingsFilter(rating, queryParams)
        })

    this._performTogglableOperation(UI_DEFAULTS_TAGS_ENABLE, UI_DEFAULTS_TAGS, (tags) => {
      this._handleDefaultTags(tags, queryParams)
    })

    let updatedParams = queryParams.toString().replaceAll('%2B', '+')
    if (updatedParams !== existingParams) {
      if (this.isPage('tagSearch') || this.isPage('uploaderSearch')) {
        location.href = location.origin + '?' + updatedParams
      } else {
        location.href = location.origin + location.pathname + '?' + updatedParams
      }
    }
  }
}

  /**
   * The site sets `#img.src` twice: first a decoy `*.hath.network/om/.../x/0/...` URL with no
   * `keystamp` token (404s), then the real `*.hath.network/h/...keystamp=...;xres=org/...` URL after
   * its P2P resolution. Only the latter is downloadable.
   *
   * @param {*} src
   * @return {boolean}
   * @private
   */
  _isValidImageSource(src)
  {
    // Real image URLs are the original (`hath.network/h/...keystamp=`) or a resample
    // (`hath.network/om/.../<width>/...`, e.g. `/1280/`). The downloader honeypot is an `/om/` URL
    // whose resample slot is the placeholder `/x/0/` (no real resample) — that one is the decoy and
    // must be skipped. Every other hath image URL is downloadable, including `/om/` resamples (which
    // never swap to a `/h/` URL, so we must NOT reject `/om/` wholesale).
    if (typeof src !== 'string' || !/hath\.network(:\d+)?\/(h|om)\//.test(src)) {
      return false
    }
    return !src.includes('/x/0/')
  }

  /**
   * Fires `callback` as soon as `#img` carries a valid (non-decoy) `src`, WITHOUT waiting for `load`.
   *
   * Resolving immediately is the whole point: the download must start the instant the tab opens (not
   * after the image finishes loading), which also keeps it reliable when many tabs are opened in quick
   * succession. `#img` may hold the real URL up front, or start as the `/x/0/` decoy and swap to the
   * real one; `_isValidImageSource` rejects the decoy, and a poll + observer catch the swap (the poll
   * is the reliable part — a `MutationObserver` alone can miss the update under heavy tab churn).
   *
   * @param {function(HTMLImageElement): void} callback
   * @private
   */
  _resolveImageForDownload(callback)
  {
    let resolved = false
    let observer = null
    let poll = null
    let timeout = null

    let cleanup = () => {
      observer?.disconnect()
      if (poll) {
        clearInterval(poll)
      }
      if (timeout) {
        clearTimeout(timeout)
      }
    }

    let attempt = () => {
      if (resolved) {
        return
      }
      let image = document.querySelector('#img')
      if (!(image instanceof HTMLImageElement) || !this._isValidImageSource(image.src)) {
        return
      }
      resolved = true
      cleanup()
      callback(image)
    }

    attempt()
    if (resolved) {
      return
    }

    observer = new MutationObserver(attempt)
    observer.observe(document.documentElement, {subtree: true, childList: true, attributes: true, attributeFilter: ['src']})
    poll = setInterval(attempt, 150)
    timeout = setTimeout(cleanup, 30000)
  }

  /**
   * @param {boolean} removeElement
   * @private
   */
  _handleDownloadMedia(removeElement)
  {
    this._resolveImageForDownload((image) => {
      let folder = this._resolveDownloadFolderFromTitle()
      let filename = image.src.split('/').pop()
      // Queue the download FIRST, while `#img` is still in the DOM with its valid `src`. `_addDownload`
      // fires `GM_download` synchronously (eager Promise executor), so the download starts immediately.
      // Guarded so a throw can't skip the node removal below.
      try {
        this._addDownload(image, folder, filename, false)
      } catch (error) {
        console.error('e-hentai download failed to enqueue:', error)
      }
      // Then remove the node DIRECTLY (plain detach, never `src=''`) so the image is not displayed and
      // its memory is freed in this tab. A direct detach does not trip `#img`'s `onerror -> nl()` retry.
      if (removeElement) {
        image.remove()
      }
    })
  }

/**
 * @return {string[][]}
 * @private
 */
_getFavouriteTagMatchRules()
{
  return this._getTagOnlyRulesFromField(UI_TAG_HIGHLIGHTS_FAVOURITE)
}

/**
 * @param {JQuery} item
 * @private
 */
_handleGalleryHighlights(item)
{
  let mode = this._getConfig(UI_GALLERY_HIGHLIGHTS)
  let itemHasHighlight = item.hasClass(STYLE_GALLERY_HIGHLIGHT)

  if (mode !== 'Disabled') {

    let itemTags = this._get(item, ITEM_TAGS), doHighlight = false, tag
    if (itemTags) {

      for (let rule of this._getFavouriteTagMatchRules()) {

        doHighlight = true
        for (let tag of rule) {

          if ((mode === 'All' && !itemTags.includes(tag)) ||
              (mode === 'Source' && ((!tag.startsWith('artist:') && !tag.startsWith('group:')) || !itemTags.includes(tag)))) {
            doHighlight = false
            break
          }
        }

        if (doHighlight) {
          if (!itemHasHighlight) {
            item.addClass(STYLE_GALLERY_HIGHLIGHT)
          }
          break
        }
      }

      if (!doHighlight && itemHasHighlight) {
        item.removeClass(STYLE_GALLERY_HIGHLIGHT)
      }
    }

  } else if (itemHasHighlight) {
    item.removeClass(STYLE_GALLERY_HIGHLIGHT)
  }
}

_handleOpenGalleries()
{
  let links = document.querySelectorAll('.' + CLASS_COMPLIANT_ITEM + ' > ' + this._config.itemLinkSelector)
  if (links.length > 0) {
    links.forEach(itemLink => window.open(itemLink.href))

    if (this._getConfig(UI_OPEN_GALLERIES_AUTO_NEXT)) {
      document.querySelector('#unext').click()
    }
  }
}

/**
 * @private
 */
async _handleOpenGalleryImages()
{
  let bypassThrottling = this._getConfig(UI_OPEN_GALLERY_PAGES_THROTTLING)
  let images = $('#gdt > a')
  let firstPageNumber = images.first().attr('href').split('-').pop()
  let maxPages = firstPageNumber + images.length - 1

  for (let page = images.length - 1; page >= 0; page--) {

    window.open(images.eq(page).attr('href'))

    if (bypassThrottling && page !== 0) {
      await Utilities.sleep(2000)
    }
  }

  if (this._getConfig(UI_OPEN_GALLERY_PAGES_AUTO_NEXT)) {

    let page = location.href.split('=')[1] ?? 0
    let pageNavs = $('.ptt td')

    maxPages = Number.parseInt(pageNavs.eq(pageNavs.length - 2).children('a').text()) - 1
    if (page < maxPages) {

      let uri = location.href
      if (page === 0) {
        uri += '?p=1'
      } else {
        uri = uri.replace('?p=' + page++, '?p=' + page)
      }
      location.href = uri
    }
  }
}

/**
 * @param {JQuery} item
 * @param {string} option
 * @private
 */
_handleRatedGalleries(item, option)
{
  let doesntComply
  switch (option) {
    case 'Blue':
      doesntComply = this._get(item, ITEM_RATED_BLUE)
      break
    case 'Green':
      doesntComply = this._get(item, ITEM_RATED_GREEN)
      break
    case 'Red':
      doesntComply = this._get(item, ITEM_RATED_RED)
      break
    case 'All':
      doesntComply = this._get(item, ITEM_RATED_BLUE) || this._get(item, ITEM_RATED_GREEN) || this._get(item, ITEM_RATED_RED)
      break
  }
  return !doesntComply
}

/**
 * @private
 */
_handleTorrentDownloadsEmbedding()
{
  let link = $('#gd5 > .g2 > a').eq(1)
  if (!link.text().endsWith('(0)')) {

    let container = $('<div class="gm"></div>').insertBefore('#cdiv')
    container.load(link.attr('onclick').replace('return popUp(\'', '').replace('\',610,590)', '') + ' form', () => {
      container.prepend('<h1 style="font-size:10pt; font-weight:bold; margin:3px; text-align:center">Torrents</h1>')
      link.parent().remove()
    })
  }
}

_handleUnderageFilter()
{
  this._configurationManager.addFlagField(FILTER_UNDERAGE_CHARACTERS_OPTION).
      setTitle('Enable Underage Females Filter').
      setHelpText('Applies the underage filter.')

  this._ageFilterHighlighter({
    configKey: UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER,
    styleClass: 'underage-tag',
    removeClasses: 'favourite-tag disliked-tag',
    helpText: "Filter and mark character tags that are not adults in any canon timelines.\nFormat: [character tag] from [parody tag] // [comments]\nExample: lynae from wuthering waves",
  })

  this._ageFilterHighlighter({
    configKey: UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER,
    styleClass: 'unknown-age-tag',
    helpText: "Specify characters which don't have officially declared age.\nFormat: [character tag] from [parody tag]\nExample: chiori from genshin impact",
  })

  this._ageFilterHighlighter({
    configKey: UI_TAG_HIGHLIGHTS_ADULT_CHARACTER,
    styleClass: 'adult-tag',
    helpText: "Specify characters checked to be adults.\nFormat: [character tag] from [parody tag] // [comments]\nExample: cantarella from wuthering waves",
  })

  this._addItemComplexComplianceFilter(
      UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER,
      (rules) => this._getConfig(FILTER_UNDERAGE_CHARACTERS_OPTION) && rules.length,
      (item) => {
        let itemTags = this._get(item, ITEM_TAGS)

        if (itemTags !== null && itemTags.length) {
          for (let rule of this._getTagOnlyRulesFromField(UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER)) {

            let isBlacklisted = true
            for (let tag of rule) {

              if (!itemTags.includes(tag)) {
                isBlacklisted = false
                break
              }
            }
            if (isBlacklisted) {
              return false
            }
          }
        }
        return true
      })

  this._configurationManager.getField(UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER)?.setTitle('Underage Characters')
  this._configurationManager.getField(UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER)?.setTitle('Unknown Age Characters')
  this._configurationManager.getField(UI_TAG_HIGHLIGHTS_ADULT_CHARACTER)?.setTitle('Adult Characters')
}

/**
 * @param {string[]} rules
 * @return {string[]}
 * @private
 */
_morphCharacterNamesToTags(rules)
{
  let morphedRules = [], tags
  for (let rule of rules) {
    tags = rule.split(' // ')[0].split(' from ')
    if (tags.length === 2) {
      morphedRules.push('character:' + tags[0] + ' & parody:' + tags[1])
    }
  }
  return morphedRules
}

/**
 * @private
 */
_setupEvents()
{
  this._onBeforeUIBuild.push(() => {

    this._performOperation(UI_VISITED_HIGHLIGHT, () => {
      GM_addStyle(`td.gl2e > div > a:visited > .glname > .glink {color: black;}`)
    })

    this._forPage('search', () => {
      this._handleDefaults(this._getConfig(UI_GALLERY_HIGHLIGHTS_COLOUR))
      GM_addStyle('.gallery-highlight,.gallery-highlight>td,.gallery-highlight>div{background-color:' + this._getConfig(UI_GALLERY_HIGHLIGHTS_COLOUR) + ' !important}.gallery-highlight{border:whitesmoke 2px solid}')
    })

    this._forPage('gallery', () => {
      this._paintGalleryPageTagHighlights($('#taglist'))
    })

    this._forPage('image', () => {
      if (this._getConfig(UI_OPEN_GALLERY_PAGES_DOWNLOAD)) {
        // Disable the UI first so it is never built/embedded on an auto-download image page,
        // regardless of the (deferred) download wiring.
        this._disableUI = true
        this._handleDownloadMedia(true)
      }
    })
  })

  this._onAfterUIBuild.push(() => {

    this._uiGen.getSelectedSection()[0].userScript = this

    this._forPage('gallery', () => {
      this._performOperation(UI_EMBED_TORRENTS, () => this._handleTorrentDownloadsEmbedding())
    })
  })

  this._onFirstHitBeforeCompliance.push((item) => this._appendGalleryTypeTag(item))
  this._onBeforeCompliance.push((item) => this._appendGalleryTypeTag(item))

  this._onItemHide = (item) => {
    if (item.is('td.gl2e')) {
      item.parent().addClass('noncompliant-item')
      item.parent().hide()
    } else {
      item.removeClass('noncompliant-item')
      item.hide()
    }
  }

  this._onItemShow.push((item) => {
    if (item.is('td.gl2e')) {
      item.parent().removeClass('noncompliant-item')
      item.parent().show()
    } else {
      item.removeClass('noncompliant-item')
      item.show()
    }
  })

  this._forPage('search', () => {
    this._onItemShow.push((item) => this._handleGalleryHighlights(item))
  })
}

/**
 * @private
 */
_setupFeatures()
{
  this._configurationManager.addColorField(UI_GALLERY_HIGHLIGHTS_COLOUR).
      setTitle('Highlight Colour').
      setHelpText('Colour to highlight the galleries with. Requires refresh to change.')
  this._configurationManager.addFlagField(FILTER_WATCHED_FROM_SEARCH).
      setTitle('Hide Watched Galleries').
      setHelpText('Hides watched galleries from searches initiated other than the watched page.')
  this._configurationManager.addFlagField(UI_OPEN_GALLERIES_AUTO_NEXT).
      setTitle('Search Auto Next').
      setHelpText('Automatically navigates to the next page after opening all galleries.')
  this._configurationManager.addFlagField(UI_OPEN_GALLERY_PAGES_AUTO_NEXT).
      setTitle('Gallery Auto Next').
      setHelpText('Automatically navigates to the next page after opening all images.')
  this._configurationManager.addFlagField(UI_OPEN_GALLERY_PAGES_DOWNLOAD).
      setTitle('Download The Image').
      setHelpText('Download image on a gallery page to a folder named after the gallery.')
  this._configurationManager.addFlagField(UI_OPEN_GALLERY_PAGES_THROTTLING).
      setTitle('Throttling Bypass').
      setHelpText('Paces the page opening logic to not trigger the site\'s throttling mechanism which limits resolution to 1280x.')
  this._configurationManager.addFlagField(UI_DEFAULTS_PAGE_RANGE_ENABLE).
      setTitle('Enable Page Range Filter').
      setHelpText('Always set these page limits in searches. Ignored if you set your own values on the page.')
  this._configurationManager.addFlagField(UI_DEFAULTS_RATING_ENABLE).
      setTitle('Enable Rating Filter').
      setHelpText('Enable default rating filter in searches')
  this._configurationManager.addFlagField(UI_DEFAULTS_TAGS_ENABLE).
      setTitle('Enable Default Tags').
      setHelpText('Enable default tags in searches.')
  this._configurationManager.addFlagField(UI_EMBED_TORRENTS).
      setTitle('Embed Torrent Downloads').
      setHelpText('Embed torrent downloads in gallery pages.')
  this._configurationManager.addFlagField(UI_VISITED_HIGHLIGHT).
      setTitle('Highlight Visited').
      setHelpText('Colours the visited gallery links black, to make them distinct.')
  this._configurationManager.addRadiosGroup(FILTER_RATED_VIDEOS, [
    ['Disabled', 'Disabled'],
    ['Red', 'Red'],
    ['Blue', 'Blue'],
    ['Green', 'Green'],
    ['All', 'All'],
  ]).
      setTitle('Hide Rated Galleries').
      setHelpText('Hides galleries rated by you with the colour set in site settings or all.')
  this._configurationManager.addRadiosGroup(UI_DEFAULTS_RATING, [
    ['2 stars', '2'],
    ['3 stars', '3'],
    ['4 stars', '4'],
    ['5 stars', '5'],
  ]).
      setTitle('Rating').
      setHelpText('Always set this rating filter in searches. Ignored if you set your own value on the page.')
  this._configurationManager.addRadiosGroup(UI_GALLERY_HIGHLIGHTS, [
    ['Disabled', 'Disabled'],
    ['All Favourite Tags', 'All'],
    ['Only Group / Artist Tags', 'Source'],
  ]).
      setTitle('Gallery Highlights').
      setHelpText('Highlights favourite galleries in search results with at least one matching tag.')
  this._configurationManager.addRangeField(UI_DEFAULTS_PAGE_RANGE, 0, 2000).
      setTitle('Page Range').
      setHelpText('Enable default page range filter in searches.')
  this._configurationManager.addRulesetField(UI_DEFAULTS_TAGS).
      setTitle('Tags').
      setRows(4).
      setHelpText('Always add the following tags in search. Can be overridden with at least one tag present.')

  this._itemAttributesResolver.
      addAttribute(ITEM_WATCHED, (item) => item.find('.gt[style],.gtl[style]').attr('style')?.startsWith('color:#f1f1f1') ?? false).
      addAttribute(ITEM_RATED_BLUE, (item) => item.find('.irb').length > 0).
      addAttribute(ITEM_RATED_GREEN, (item) => item.find('.irg').length > 0).
      addAttribute(ITEM_RATED_RED, (item) => item.find('.irr').length > 0)
}

_setupFilters()
{
  // Gallery `#taglist` highlights are painted in `_setupEvents` after page detection
  // (`isPage('gallery')` is false during the constructor).

  this._addItemComplexComplianceFilter(
      FILTER_RATED_VIDEOS,
      (option) => option !== 'Disabled',
      (item, option) => this._handleRatedGalleries(item, option))

  this._addItemComplexComplianceFilter(
      FILTER_WATCHED_FROM_SEARCH,
      (enabled) => !this.isPage('gallery') && !this.isPage('watched') && enabled,
      (item) => !this._get(item, ITEM_WATCHED))

  this._addItemTagHighlights({
    configKey: UI_TAG_HIGHLIGHTS_FAVOURITE,
    styleClass: 'favourite-tag',
    rows: 10,
    helpText: 'Specify favourite tags to highlight.',
  })
  this._configurationManager.getField(UI_TAG_HIGHLIGHTS_FAVOURITE)?.setTitle('Favourite Tags')

  this._addItemTagHighlights({
    configKey: UI_TAG_HIGHLIGHTS_DISLIKED,
    styleClass: 'disliked-tag',
    rows: 10,
    helpText: 'Specify disliked tags to highlight.',
  })
  this._configurationManager.getField(UI_TAG_HIGHLIGHTS_DISLIKED)?.setTitle('Disliked Tags')

  this._addItemTagBlacklistFilter(ITEM_TAGS, false, 20)
  this._addItemBlacklistFilter('Hide galleries with specified phrases in their names.', 10)

  this._handleUnderageFilter()
}

/**
 * @private
 */
_setupUI()
{
  let pageSpecificButton, statistics = []
  if (this.isPage('gallery')) {

    pageSpecificButton = this._uiGen.createFormButton(
        'Open Gallery Images',
        'Opens all images on current page of this gallery.',
        () => this._handleOpenGalleryImages())

  } else if (this.isPage('image')) {

    pageSpecificButton = this._uiGen.createFormButton(
        'Download Image',
        'Downloads the image on the page.',
        () => this._handleDownloadMedia(false))

  } else {

    pageSpecificButton = this._uiGen.createFormButton(
        'Open All Galleries',
        'Opens all galleries on current page.',
        () => this._handleOpenGalleries())
    statistics = [
      this._uiGen.createStatisticsFormGroup(FILTER_TEXT_BLACKLIST, 'Text Blacklist'),
      this._uiGen.createStatisticsFormGroup(FILTER_TAG_BLACKLIST, 'Tag Blacklist'),
      this._uiGen.createStatisticsFormGroup(FILTER_RATED_VIDEOS, 'Hide Rated Galleries'),
      this._uiGen.createStatisticsFormGroup(UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER, 'Underage Characters'),
      this.isPage('watched') ? '' : this._uiGen.createStatisticsFormGroup(FILTER_WATCHED_FROM_SEARCH, 'Hide Watched Galleries'),
    ]
  }

  this._userInterface = [
    this._uiGen.createTabsSection(['Filters', 'Filters 2', 'Underage Filter', 'Galleries', 'Tag Highlights', 'Search Defaults', 'Extras'], [
      this._uiGen.createTabPanel('Filters', true).append([
        this._configurationManager.createElement(FILTER_WATCHED_FROM_SEARCH),
        this._configurationManager.createElement(OPTION_ENABLE_TAG_BLACKLIST),
        this._configurationManager.createElement(FILTER_TAG_BLACKLIST),
        this._configurationManager.createElement(OPTION_DISABLE_COMPLIANCE_VALIDATION),
      ]),
      this._uiGen.createTabPanel('Filters 2').append([
        this._configurationManager.createElement(FILTER_RATED_VIDEOS),
        this._uiGen.createSeparator(),
        this._configurationManager.createElement(OPTION_ENABLE_TEXT_BLACKLIST),
        this._configurationManager.createElement(FILTER_TEXT_BLACKLIST),
      ]),
      this._uiGen.createTabPanel('Galleries').append([
        this._uiGen.createTitle('Open Galleries'),
        this._configurationManager.createElement(UI_OPEN_GALLERIES_AUTO_NEXT),
        this._uiGen.createSeparator(),
        this._uiGen.createTitle('Open Images'),
        this._configurationManager.createElement(UI_OPEN_GALLERY_PAGES_AUTO_NEXT),
        this._configurationManager.createElement(UI_OPEN_GALLERY_PAGES_THROTTLING),
        this._uiGen.createSeparator(),
        this._uiGen.createTitle('Image Pages'),
        this._configurationManager.createElement(UI_OPEN_GALLERY_PAGES_DOWNLOAD),
        this._uiGen.createSeparator(),
        this._configurationManager.createElement(UI_GALLERY_HIGHLIGHTS),
        this._uiGen.createBreakSeparator(),
        this._uiGen.createBreakSeparator(),
        this._configurationManager.createElement(UI_GALLERY_HIGHLIGHTS_COLOUR),
      ]),
      this._uiGen.createTabPanel('Tag Highlights').append([
        this._configurationManager.createElement(UI_TAG_HIGHLIGHTS_FAVOURITE),
        this._configurationManager.createElement(UI_TAG_HIGHLIGHTS_DISLIKED),
      ]),
      this._uiGen.createTabPanel('Search Defaults').append([
        this._configurationManager.createElement(UI_DEFAULTS_PAGE_RANGE_ENABLE),
        this._configurationManager.createElement(UI_DEFAULTS_PAGE_RANGE),
        this._uiGen.createSeparator(),
        this._configurationManager.createElement(UI_DEFAULTS_RATING),
        this._uiGen.createBreakSeparator(),
        this._configurationManager.createElement(UI_DEFAULTS_RATING_ENABLE),
        this._uiGen.createSeparator(),
        this._configurationManager.createElement(UI_DEFAULTS_TAGS_ENABLE),
        this._configurationManager.createElement(UI_DEFAULTS_TAGS),
      ]),
      this._uiGen.createTabPanel('Underage Filter').append([
        this._configurationManager.createElement(FILTER_UNDERAGE_CHARACTERS_OPTION),
        this._configurationManager.createElement(UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER),
        this._configurationManager.createElement(UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER),
        this._configurationManager.createElement(UI_TAG_HIGHLIGHTS_ADULT_CHARACTER),
      ]),
      this._uiGen.createTabPanel('Extras').append([
        this._configurationManager.createElement(UI_EMBED_TORRENTS),
        this._configurationManager.createElement(UI_VISITED_HIGHLIGHT),
        this._configurationManager.createElement(OPTION_ALWAYS_SHOW_SETTINGS_PANE),
        this._uiGen.createSeparator(),
        this._createSettingsBackupRestoreFormActions(),
      ]),
    ]),
    this._uiGen.createBottomSection([
      ...statistics,
      this._uiGen.createSeparator(),
      pageSpecificButton,
      this._uiGen.createSeparator(),
      this._createSettingsFormActions(),
    ]),
  ]
}
}

void (new EHentaiSearchAndUITweaks).init()