Brazen Framework - Tag Query Engine

Tokenize, merge, and subtract space-separated booru tag queries

이 스크립트는 직접 설치하는 용도가 아닙니다. 다른 스크립트에서 메타 지시문 // @require https://update.greasyfork.org/scripts/583965/1901980/Brazen%20Framework%20-%20Tag%20Query%20Engine.js을(를) 사용하여 포함하는 라이브러리입니다.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Brazen Framework - Tag Query Engine
// @namespace    brazenvoid
// @version      1.2.0
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Tokenize, merge, and subtract space-separated booru tag queries; includes Gelbooru-family search adapter (more adapters may be added later)
// @run-at       document-end
// ==/UserScript==

/**
 * Core tag-query engine: parenthesis-aware tokenize, idempotent merge, subtract for display.
 * Site-specific URL/param handling lives in adapters (e.g. GelbooruFamilySearchAdapter) in this same module.
 */
class BrazenTagQueryEngine
{
  // -------------------------------------------------------------------------
  // Static public methods
  // -------------------------------------------------------------------------

  /**
   * Parenthesis-depth-aware scan for `:` that introduces Gelbooru query-syntax (`key:value`).
   * Only returns an index when the prefix before `:` (at depth 0) is in `knownKeys`.
   *
   * @param {string} text
   * @param {Set<string>|Iterable<string>} [knownKeys]
   * @return {number}
   */
  static indexOfQuerySyntaxSeparator(text, knownKeys = GELBOORU_QUERY_SYNTAX_KEYS)
  {
    let value = String(text ?? '')
    let keySet = knownKeys instanceof Set ? knownKeys : new Set(knownKeys)
    let depth = 0
    for (let index = 0; index < value.length; index++) {
      let ch = value[index]
      if (ch === '(') {
        depth++
        continue
      }
      if (ch === ')') {
        if (depth > 0) {
          depth--
        }
        continue
      }
      if (ch === ':' && depth === 0 && index > 0) {
        let key = value.slice(0, index)
        if (keySet.has(key)) {
          return index
        }
      }
    }
    return -1
  }

  /**
   * Strip leading Gelbooru prefix operators (`-`, `*`) before query-syntax inspection.
   * @param {string} token
   * @return {string|null} subject, or null when absent / parenthesis-led
   * @private
   */
  static _querySyntaxSubject(token)
  {
    let subject = String(token ?? '')
    if (!subject || subject.startsWith('(')) {
      return null
    }
    let peeling = true
    while (peeling && subject.length > 1) {
      peeling = false
      if (subject.startsWith('-')) {
        subject = subject.slice(1)
        peeling = true
      } else if (subject.startsWith('*')) {
        subject = subject.slice(1)
        peeling = true
      }
    }
    return subject || null
  }

  /**
   * @param {string} token
   * @param {Set<string>|Iterable<string>} [knownKeys]
   * @return {boolean}
   */
  static isQuerySyntaxToken(token, knownKeys = GELBOORU_QUERY_SYNTAX_KEYS)
  {
    let subject = BrazenTagQueryEngine._querySyntaxSubject(token)
    if (!subject) {
      return false
    }
    return BrazenTagQueryEngine.indexOfQuerySyntaxSeparator(subject, knownKeys) > 0
  }

  /**
   * @param {string} token
   * @param {Set<string>|Iterable<string>} [knownKeys]
   * @return {string|null}
   */
  static defaultMetatagKey(token, knownKeys = GELBOORU_QUERY_SYNTAX_KEYS)
  {
    let parsed = GelbooruFamilySearchAdapter.parseMetatag(token, knownKeys)
    return parsed ? parsed.key : null
  }

  // -------------------------------------------------------------------------
  // Constructor
  // -------------------------------------------------------------------------

  /**
   * @param {{singleInstanceKeys?: Set<string>, metatagKey?: function(string): (string|null)}} [options]
   */
  constructor(options = {})
  {
    this._singleInstanceKeys = options.singleInstanceKeys ?? new Set()
    this._metatagKey = options.metatagKey ?? BrazenTagQueryEngine.defaultMetatagKey
  }

  // -------------------------------------------------------------------------
  // Public class methods
  // -------------------------------------------------------------------------

  /**
   * @param {string} query
   * @return {string[]}
   */
  tokenize(query)
  {
    let tokens = []
    if (!query) {
      return tokens
    }
    let index = 0
    let length = query.length

    while (index < length) {

      while (index < length && /\s/.test(query[index])) {
        index++
      }
      if (index >= length) {
        break
      }

      let start = index
      if (query[index] === '(') {

        let depth = 0
        while (index < length) {
          if (query[index] === '(') {
            depth++
          } else if (query[index] === ')') {
            depth--
            if (depth === 0) {
              index++
              break
            }
          }
          index++
        }

      } else {
        while (index < length && !/\s/.test(query[index])) {
          index++
        }
      }
      tokens.push(query.slice(start, index))
    }
    return tokens
  }

  /**
   * @param {string} token
   * @return {string|null}
   */
  metatagKey(token)
  {
    return this._metatagKey(token)
  }

  /**
   * @param {string} userQuery
   * @param {string[]} defaultTokens
   * @return {string[]}
   */
  merge(userQuery, defaultTokens)
  {
    let userTokens = this.tokenize(userQuery)
    let userKeys = new Set()
    let present = new Set(userTokens)

    for (let token of userTokens) {
      let key = this.metatagKey(token)
      if (key) {
        userKeys.add(key)
      }
    }

    let merged = [...userTokens]
    for (let token of defaultTokens) {

      let key = this.metatagKey(token)
      if (key && this._singleInstanceKeys.has(key) && userKeys.has(key)) {
        continue
      }
      if (present.has(token)) {
        continue
      }
      merged.push(token)
      present.add(token)
    }
    return merged
  }

  /**
   * @param {string[]} tokens
   * @param {string[]} defaultTokens
   * @return {string[]}
   */
  subtract(tokens, defaultTokens)
  {
    let defaults = new Set(defaultTokens)
    return tokens.filter((token) => !defaults.has(token))
  }

  /**
   * Remove tokens whose metatag key appears in `keys` (any operator — e.g. all `width:*`).
   *
   * @param {string[]} tokens
   * @param {Iterable<string>} keys
   * @return {string[]}
   */
  stripMetatagKeys(tokens, keys)
  {
    let keySet = keys instanceof Set ? keys : new Set(keys)
    if (!keySet.size) {
      return [...tokens]
    }
    return tokens.filter((token) => {
      let key = this.metatagKey(token)
      return !key || !keySet.has(key)
    })
  }
}

/** Gelbooru search query-syntax keys (`key:value` operators — not tagTypes `meta`). */
const GELBOORU_QUERY_SYNTAX_KEYS = new Set([
  'sort', 'rating', 'user', 'md5', 'parent', 'width', 'height', 'score',
  'aspectratio', 'aspectratiof', 'sourcedomains',
])

/** Metatags Gelbooru cannot combine more than once per query. */
const GELBOORU_SINGLE_INSTANCE_METATAGS = new Set([
  'sort', 'rating', 'user', 'md5', 'parent', 'score',
  'aspectratio', 'aspectratiof', 'sourcedomains', 'width', 'height',
])

/** Resolution filter metatag keys on Gelbooru-family sites (`width:>=N`, `height:<N`, …). */
const GELBOORU_RESOLUTION_METATAG_KEYS = ['width', 'height']

/**
 * Canonical Gelbooru `tagTypes` reference data. Each entry carries the site↔canonical mapping
 * resolved through `TagRepository.resolveCanonicalTypeName`: `sidebarClass` matches the
 * sidebar/autocomplete category slug and `apiEntryId` the Gelbooru numeric category.
 * @type {Array<{entryId: number, name: string, label: string, sortOrder: number, sidebarClass: string, apiEntryId: number}>}
 */
const GELBOORU_CANONICAL_TAG_TYPES = [
  {entryId: 2, name: 'author', label: 'Artist', sortOrder: 0, sidebarClass: 'artist', apiEntryId: 1},
  {entryId: 3, name: 'copyright', label: 'Series', sortOrder: 1, sidebarClass: 'copyright', apiEntryId: 3},
  {entryId: 4, name: 'character', label: 'Characters', sortOrder: 2, sidebarClass: 'character', apiEntryId: 4},
  {entryId: 5, name: 'meta', label: 'Meta', sortOrder: 3, sidebarClass: 'metadata', apiEntryId: 5},
  {entryId: 6, name: 'general', label: 'General', sortOrder: 4, sidebarClass: 'general', apiEntryId: 0},
]

/** @type {Record<string, string>} */
const _GELBOORU_SIDEBAR_CLASS_TO_TYPE = Object.freeze((() => {
  /** @type {Record<string, string>} */
  let map = Object.create(null)
  for (let row of GELBOORU_CANONICAL_TAG_TYPES) {
    map[row.sidebarClass] = row.name
  }
  map.artist = 'author'
  return map
})())

/** @type {Record<string, string>} */
const _GELBOORU_TYPE_TO_SIDEBAR_CLASS = Object.freeze((() => {
  /** @type {Record<string, string>} */
  let map = Object.create(null)
  for (let row of GELBOORU_CANONICAL_TAG_TYPES) {
    map[row.name] = 'tag-type-' + row.sidebarClass
  }
  return map
})())

/** @type {Record<number, string>} */
const _GELBOORU_ENTRY_ID_TO_TYPE = Object.freeze((() => {
  /** @type {Record<number, string>} */
  let map = Object.create(null)
  for (let row of GELBOORU_CANONICAL_TAG_TYPES) {
    map[row.entryId] = row.name
  }
  return map
})())

/** @type {Record<string, number>} */
const _GELBOORU_TYPE_TO_ENTRY_ID = Object.freeze((() => {
  /** @type {Record<string, number>} */
  let map = Object.create(null)
  for (let row of GELBOORU_CANONICAL_TAG_TYPES) {
    map[row.name] = row.entryId
  }
  return map
})())

/**
 * Map a Gelbooru sidebar CSS class suffix (`artist`, `metadata`, …) to a canonical type name.
 * Includes the `artist` → `author` alias from canonical rows.
 *
 * @param {string} sidebarClass
 * @return {string}
 */
function sidebarClassToType(sidebarClass)
{
  let key = String(sidebarClass ?? '').toLowerCase()
  if (!key) {
    return 'general'
  }
  return _GELBOORU_SIDEBAR_CLASS_TO_TYPE[key] ?? 'general'
}

/**
 * Map a canonical type name to the Gelbooru sidebar row class (`tag-type-artist`, …).
 *
 * @param {string} typeName
 * @return {string}
 */
function typeToSidebarClass(typeName)
{
  let key = String(typeName ?? '').toLowerCase()
  if (!key) {
    return 'tag-type-general'
  }
  return _GELBOORU_TYPE_TO_SIDEBAR_CLASS[key] ?? 'tag-type-general'
}

/**
 * Resolve a seeded `tagTypes` `entryId` to a canonical type name.
 *
 * @param {number|string} entryId
 * @return {string|null}
 */
function typeFromEntryId(entryId)
{
  let id = Number(entryId)
  if (!Number.isFinite(id)) {
    return null
  }
  return _GELBOORU_ENTRY_ID_TO_TYPE[id] ?? null
}

/**
 * Resolve a canonical type name to its seeded `tagTypes` `entryId`.
 *
 * @param {string} typeName
 * @return {number|null}
 */
function entryIdFromType(typeName)
{
  let key = String(typeName ?? '').toLowerCase()
  if (!key) {
    return null
  }
  return _GELBOORU_TYPE_TO_ENTRY_ID[key] ?? null
}

/**
 * @typedef {object} GelbooruFamilySearchAdapterOptions
 * @property {string} [queryParam]
 * @property {string[]} [emptySentinels]
 * @property {string} [listPath]
 * @property {Set<string>} [singleInstanceKeys]
 */

/**
 * Gelbooru 0.2 HTML search adapter (rule34.xxx, gelbooru.com list pages).
 * Per-host URL variants (e.g. rule34.us `r=posts/index&q=`) override `listPath` / `queryParam`.
 * Future site-family drivers stay as separate classes in this module.
 *
 * Gelbooru metatags use `key:operatorvalue` (`width:>=1000`, `rating:safe`). Comparison
 * operators are `>=`, `<=`, `<`, `>`, `=`; only the key (`width`, `height`, …) is shared
 * across operator variants.
 */
class GelbooruFamilySearchAdapter
{
  // -------------------------------------------------------------------------
  // Static public methods
  // -------------------------------------------------------------------------

  /**
   * Parse a Gelbooru-family metatag token.
   *
   * @param {string} token
   * @param {Set<string>|Iterable<string>} [knownKeys]
   * @return {{key: string, operator: string, value: string}|null}
   */
  static parseMetatag(token, knownKeys = GELBOORU_QUERY_SYNTAX_KEYS)
  {
    let subject = GelbooruFamilySearchAdapter._querySyntaxSubject(token)
    if (!subject) {
      return null
    }
    let colon = BrazenTagQueryEngine.indexOfQuerySyntaxSeparator(subject, knownKeys)
    if (colon <= 0) {
      return null
    }
    let key = subject.slice(0, colon)
    let rest = subject.slice(colon + 1)
    let match = rest.match(/^(>=|<=|>|<|=)(.*)$/)
    if (match) {
      return {key, operator: match[1], value: match[2]}
    }
    return {key, operator: '', value: rest}
  }

  /**
   * @param {string} token
   * @param {Set<string>|Iterable<string>} [knownKeys]
   * @return {string|null}
   */
  static metatagKey(token, knownKeys = GELBOORU_QUERY_SYNTAX_KEYS)
  {
    let parsed = GelbooruFamilySearchAdapter.parseMetatag(token, knownKeys)
    return parsed ? parsed.key : null
  }

  /**
   * Strip leading Gelbooru prefix operators (`-`, `*`) before query-syntax inspection.
   * @param {string} token
   * @return {string|null}
   * @private
   */
  static _querySyntaxSubject(token)
  {
    return BrazenTagQueryEngine._querySyntaxSubject(token)
  }

  /**
   * Idempotently reconcile the gelbooru `apis` entry: seed and merge `querySyntaxKeys` from the
   * canonical Gelbooru query grammar.
   *
   * @param {*} repos
   * @return {Promise<void>}
   */
  static async reconcileQuerySyntaxKeys(repos)
  {
    let canonical = [...GELBOORU_QUERY_SYNTAX_KEYS]
    let apis = await repos.storage.get(IDB_STORE_APIS, 'apis')
    if (!apis?.entries?.length) {
      await repos.storage.put(IDB_STORE_APIS, {
        id: 'apis',
        entries: [{
          entryId: 1,
          name: 'gelbooru',
          label: 'Gelbooru',
          querySyntaxKeys: canonical,
        }],
      })
      repos.setQuerySyntaxKeysCache?.(canonical)
      return
    }
    let gelbooru = apis.entries.find((row) => row.name === 'gelbooru')
    if (!gelbooru) {
      apis.entries.push({
        entryId: 1,
        name: 'gelbooru',
        label: 'Gelbooru',
        querySyntaxKeys: canonical,
      })
      await repos.storage.put(IDB_STORE_APIS, apis)
      repos.setQuerySyntaxKeysCache?.(canonical)
      return
    }
    let changed = false
    if (!Array.isArray(gelbooru.querySyntaxKeys) || !gelbooru.querySyntaxKeys.length) {
      gelbooru.querySyntaxKeys = [...canonical]
      changed = true
    } else {
      let merged = [...gelbooru.querySyntaxKeys]
      for (let key of canonical) {
        if (!merged.includes(key)) {
          merged.push(key)
          changed = true
        }
      }
      if (changed) {
        gelbooru.querySyntaxKeys = merged
      }
    }
    if (changed) {
      await repos.storage.put(IDB_STORE_APIS, apis)
    }
    repos.setQuerySyntaxKeysCache?.(gelbooru.querySyntaxKeys)
  }

  /**
   * Idempotently reconcile the `tagTypes` reference document: seed when empty, otherwise backfill
   * `sidebarClass` / `apiEntryId` onto canonical entries and drop the legacy `artist` alias.
   *
   * @param {*} repos
   * @return {Promise<void>}
   */
  static async reconcileTagTypes(repos)
  {
    let tagTypes = await repos.storage.get(IDB_STORE_TAG_TYPES, 'tagTypes')
    if (!tagTypes?.entries?.length) {
      await repos.storage.put(IDB_STORE_TAG_TYPES, {
        id: 'tagTypes',
        entries: GELBOORU_CANONICAL_TAG_TYPES.map((row) => ({...row})),
      })
      return
    }
    let byName = new Map(GELBOORU_CANONICAL_TAG_TYPES.map((row) => [row.name, row]))
    let changed = false
    let entries = tagTypes.entries.filter((row) => {
      if (row.name === 'artist' && row.aliasOfEntryIds?.length) {
        changed = true
        return false
      }
      return true
    })
    for (let row of entries) {
      let canonical = byName.get(row.name)
      if (!canonical) {
        continue
      }
      if (row.sidebarClass == null && canonical.sidebarClass != null) {
        row.sidebarClass = canonical.sidebarClass
        changed = true
      }
      if (row.apiEntryId == null && canonical.apiEntryId != null) {
        row.apiEntryId = canonical.apiEntryId
        changed = true
      }
    }
    if (changed) {
      await repos.storage.put(IDB_STORE_TAG_TYPES, {...tagTypes, entries})
    }
  }

  /**
   * Reconcile all Gelbooru-family reference data (`apis.querySyntaxKeys`, `tagTypes` mapping).
   *
   * @param {*} repos
   * @return {Promise<void>}
   */
  static async reconcileReferenceData(repos)
  {
    await GelbooruFamilySearchAdapter.reconcileQuerySyntaxKeys(repos)
    await GelbooruFamilySearchAdapter.reconcileTagTypes(repos)
  }

  // -------------------------------------------------------------------------
  // Constructor
  // -------------------------------------------------------------------------

  /**
   * @param {GelbooruFamilySearchAdapterOptions} [options]
   */
  constructor(options = {})
  {
    this.queryParam = options.queryParam ?? 'tags'
    this.emptySentinels = options.emptySentinels ?? ['all']
    this.listPath = options.listPath ?? '/index.php?page=post&s=list'
    this.singleInstanceKeys = options.singleInstanceKeys ?? GELBOORU_SINGLE_INSTANCE_METATAGS
    this.engine = new BrazenTagQueryEngine({
      singleInstanceKeys: this.singleInstanceKeys,
      metatagKey: GelbooruFamilySearchAdapter.metatagKey,
    })
  }

  // -------------------------------------------------------------------------
  // Public class methods
  // -------------------------------------------------------------------------

  /**
   * @param {Location|string} [locationRef]
   * @return {string}
   */
  readQuery(locationRef = location)
  {
    let params = new URLSearchParams(typeof locationRef === 'string' ? new URL(locationRef).search : locationRef.search)
    return this.normalizeQueryParam(params.get(this.queryParam) ?? '')
  }

  /**
   * Treats empty sentinels (e.g. `tags=all`) as an empty user query for default merging.
   *
   * @param {string|null} tags
   * @return {string}
   */
  normalizeQueryParam(tags)
  {
    if (!tags) {
      return ''
    }
    let joined = this.engine.tokenize(tags).join(' ')
    if (this.emptySentinels.includes(joined)) {
      return ''
    }
    return tags
  }

  /**
   * @param {string[]} tokens
   * @return {string}
   */
  serializeQuery(tokens)
  {
    return tokens.join(' ')
  }

  /**
   * @param {string} serialized
   * @param {string} [origin]
   * @return {string}
   */
  buildListUrl(serialized, origin = location.origin)
  {
    return origin + this.listPath + '&' + this.queryParam + '=' + encodeURIComponent(serialized)
  }

  /**
   * Lowercase tag atoms; preserve metatag keys and OR-group syntax.
   *
   * @param {string} tag
   * @return {string}
   */
  normalizeAtom(tag)
  {
    tag = tag.trim()
    if (!tag) {
      return tag
    }

    let negated = false
    if (tag.startsWith('-')) {
      negated = true
      tag = tag.slice(1).trimStart()
    }

    if (tag.startsWith('(') && tag.endsWith(')')) {
      let inner = tag.slice(1, -1)
      let parts = inner.split('~').map((part) => this.normalizeAtom(part.trim()))
      tag = '(' + parts.join(' ~ ') + ')'
      return negated ? '-' + tag : tag
    }

    let parsed = GelbooruFamilySearchAdapter.parseMetatag(tag)
    if (parsed) {
      if (this.singleInstanceKeys.has(parsed.key)) {
        return negated ? '-' + tag : tag
      }
      let valuePart = (parsed.operator + parsed.value).trim().replace(/\s+/g, '_').toLowerCase()
      tag = parsed.key + ':' + valuePart
      return negated ? '-' + tag : tag
    }

    tag = tag.replace(/\s+/g, '_').toLowerCase()
    return negated ? '-' + tag : tag
  }

  /**
   * User-visible tokens: drop script-managed Gelbooru metatag keys, then exact default lines.
   *
   * @param {string[]} tokens
   * @param {string[]} defaultTokens
   * @param {Iterable<string>} [managedMetatagKeys]
   * @return {string[]}
   */
  extractUserTokens(tokens, defaultTokens, managedMetatagKeys = [])
  {
    let managed = managedMetatagKeys instanceof Set ? managedMetatagKeys : new Set(managedMetatagKeys)
    let stripped = managed.size ? this.engine.stripMetatagKeys(tokens, managed) : [...tokens]
    return this.engine.subtract(stripped, defaultTokens)
  }

  /**
   * Replace script-managed Gelbooru metatags, then merge default tokens into the query.
   *
   * @param {string} userQuery
   * @param {string[]} defaultTokens
   * @param {Iterable<string>} [managedMetatagKeys]
   * @return {string[]}
   */
  reconcileDefaults(userQuery, defaultTokens, managedMetatagKeys = [])
  {
    let keysToReplace = new Set(managedMetatagKeys)
    for (let token of defaultTokens) {
      let key = GelbooruFamilySearchAdapter.metatagKey(token)
      if (key) {
        keysToReplace.add(key)
      }
    }
    let userTokens = this.engine.tokenize(userQuery)
    let stripped = keysToReplace.size ? this.engine.stripMetatagKeys(userTokens, keysToReplace) : userTokens
    return this.engine.merge(stripped.join(' '), defaultTokens)
  }
}

/**
 * Gelbooru-family DOM helpers (media pages, list items, tag sidebar).
 */
class GelbooruFamilyDocument
{
  /**
   * @param {ParentNode|Document|null|undefined} doc
   * @return {string}
   */
  static getOriginalMediaUrlFromDocument(doc)
  {
    if (!doc?.querySelectorAll) {
      return ''
    }
    for (let anchor of doc.querySelectorAll('.link-list a')) {
      if (!/original\s*image/i.test(anchor.textContent ?? '')) {
        continue
      }
      let href = (anchor.getAttribute('href') ?? '').trim()
      if (!href || href === '#' || /^javascript:/i.test(href)) {
        continue
      }
      return href
    }
    return ''
  }

  /**
   * @param {Document} doc
   * @return {boolean}
   */
  static hasResolvableMediaDoc(doc)
  {
    return !!(doc.querySelector('#image') || doc.querySelector('#gelcomVideoPlayer'))
  }

  /**
   * @param {Document} doc
   * @return {boolean}
   */
  static is429RateLimitDoc(doc)
  {
    if (GelbooruFamilyDocument.hasResolvableMediaDoc(doc)) {
      return false
    }
    let styleText = doc.querySelector?.('.style1')?.textContent ?? ''
    return /429\s+Rate limiting/i.test(doc.title || '')
        || styleText.includes('429 Rate limiting')
        || doc.body?.textContent?.includes('429 Rate limiting')
  }

  /**
   * @param {Document} doc
   * @return {boolean}
   */
  static isCloudflareChallengeDoc(doc)
  {
    if (GelbooruFamilyDocument.hasResolvableMediaDoc(doc)) {
      return false
    }
    if (GelbooruFamilyDocument.is429RateLimitDoc(doc)) {
      return false
    }
    let html = doc.documentElement?.innerHTML ?? ''
    let text = doc.body?.textContent ?? ''
    let title = doc.title || ''
    return /cdn-cgi\/challenge-platform/i.test(html)
        || /_cf_chl_opt/i.test(html)
        || /Please enter the CAPTCHA/i.test(text)
        || /\bCAPTCHA\b/i.test(title)
        || !!doc.querySelector?.('.captcha-box, #challenge-error-text, .cf-turnstile, .cf-challenge, #challenge-form, #cf-challenge-running')
        || /challenges\.cloudflare\.com/i.test(html)
        || /__CF\$cv\$params/i.test(html)
        || /Just a moment/i.test(title)
        || /Checking your browser/i.test(text)
        || /Enable JavaScript and cookies/i.test(text)
  }

  /**
   * Append Gelbooru resolution metatags (`width:>=N`, `height:<=N`; 0 bounds omitted).
   * Format is Gelbooru-specific: key, `:`, then comparison operator and pixel value.
   *
   * @param {string[]} tokens
   * @param {'width'|'height'|string} key
   * @param {{minimum: *, maximum: *}} range
   */
  static appendRangeMetatags(tokens, key, range)
  {
    let minimum = Number.parseInt(range.minimum, 10) || 0
    let maximum = Number.parseInt(range.maximum, 10) || 0
    if (minimum > 0) {
      tokens.push(key + ':>=' + minimum)
    }
    if (maximum > 0) {
      tokens.push(key + ':<=' + maximum)
    }
  }

  /**
   * @param {HTMLElement} item
   * @param {{itemLinkSelector?: string, thumbnailSelector?: string}} [selectors]
   * @return {number|null}
   */
  static getPostIdFromListItem(item, selectors = {})
  {
    let linkSelector = selectors.itemLinkSelector ?? 'a'
    let thumbSelector = selectors.thumbnailSelector ?? 'img.preview'
    let thumbId = item.getAttribute?.('id') ?? item.id
    if (thumbId && thumbId.startsWith('s')) {
      let id = Number.parseInt(thumbId.slice(1), 10)
      if (!Number.isNaN(id)) {
        return id
      }
    }
    let link = item.querySelector(linkSelector)
    if (!link) {
      return null
    }
    let linkId = link.getAttribute('id') ?? link.id
    if (linkId && linkId.startsWith('p')) {
      let id = Number.parseInt(linkId.slice(1), 10)
      if (!Number.isNaN(id)) {
        return id
      }
    }
    let href = link.getAttribute('href') ?? ''
    let match = href.match(/[?&]id=(\d+)/)
    if (match) {
      let id = Number.parseInt(match[1], 10)
      if (!Number.isNaN(id)) {
        return id
      }
    }
    return null
  }

  /**
   * Standard Gelbooru-family page detectors for `BrazenFramework.definePages`.
   *
   * @param {{getQueryParams?: function(): URLSearchParams, homeStaticIndexId?: string}} [options]
   * @return {{home: function(): boolean, search: function(): boolean, media: function(): boolean, mediaRateLimited: function(): boolean, mediaCloudflare: function(): boolean}}
   */
  static definePages(options = {})
  {
    let getParams = options.getQueryParams ?? (() => new URLSearchParams(location.search))
    let homeStaticIndexId = options.homeStaticIndexId ?? 'static-index'
    return {
      home: () => {
        let params = getParams()
        return !!document.getElementById(homeStaticIndexId)
            || (!params.get('page')
                && /^\/(?:index\.php)?$/i.test(location.pathname))
      },
      search: () => {
        let params = getParams()
        return params.get('page') === 'post' && params.get('s') === 'list'
      },
      media: () => {
        let params = getParams()
        return params.get('page') === 'post' && params.get('s') === 'view'
            && !GelbooruFamilyDocument.is429RateLimitDoc(document)
            && !GelbooruFamilyDocument.isCloudflareChallengeDoc(document)
      },
      mediaRateLimited: () => {
        let params = getParams()
        return params.get('page') === 'post' && params.get('s') === 'view'
            && GelbooruFamilyDocument.is429RateLimitDoc(document)
      },
      mediaCloudflare: () => {
        let params = getParams()
        return params.get('page') === 'post' && params.get('s') === 'view'
            && !GelbooruFamilyDocument.is429RateLimitDoc(document)
            && GelbooruFamilyDocument.isCloudflareChallengeDoc(document)
      },
    }
  }

  /**
   * Resolve media download fields from a fetched Gelbooru-family post document.
   *
   * @param {Document} doc
   * @param {{sourceUrl?: string}} ctx
   * @param {{statsSelector?: string, sidebarRowSelector?: string, decodeHtmlEntities?: function(string): string, normalizeAtom?: function(string): string}} [options]
   * @return {Promise<{mediaUrl: string, tagGroups: Record<string, string[]>, tagIncidences: Record<string, number>, metadata: {sourceUrl?: string}, downloadId: string|null, data: object}>}
   */
  static async resolvePostFromDocument(doc, ctx = {}, options = {})
  {
    let statsSelector = options.statsSelector ?? '#stats'
    let decodeHtmlEntities = options.decodeHtmlEntities ?? ((value) => value)
    let normalizeAtom = options.normalizeAtom ?? ((tag) => tag)
    let mediaUrl = GelbooruFamilyDocument.getOriginalMediaUrlFromDocument(doc)
    let url = new URL(ctx.sourceUrl ?? location.href)
    let data = {
      id: url.searchParams.get('id') ?? '',
      md5: '',
      ext: '',
      width: '',
      height: '',
      score: '',
      rating: '',
      source: '',
      sourcedomain: '',
    }
    if (mediaUrl) {
      let fileName = mediaUrl.split('?')[0].split('/').pop()
      data.ext = fileName.includes('.') ? fileName.split('.').pop() : ''
      let base = data.ext ? fileName.slice(0, -(data.ext.length + 1)) : fileName
      data.md5 = base.replace(/^sample_/, '')
    }
    if (doc?.querySelectorAll) {
      doc.querySelectorAll(statsSelector + ' li').forEach((element) => {
        let text = decodeHtmlEntities(element.textContent?.trim() ?? '')
        let size = text.match(/Size:\s*(\d+)\s*x\s*(\d+)/i)
        if (size) {
          data.width = size[1]
          data.height = size[2]
        }
      })
    }
    let {tagGroups, tagIncidences} = GelbooruFamilyDocument.getMediaTagGroupsAndIncidencesFromRoot(doc, {
      decodeHtmlEntities,
      normalizeAtom,
      sidebarRowSelector: options.sidebarRowSelector,
    })
    return {
      mediaUrl,
      tagGroups,
      tagIncidences,
      metadata: {sourceUrl: ctx.sourceUrl},
      downloadId: data.id || null,
      data,
    }
  }

  /**
   * @param {HTMLElement} element
   * @return {string|null}
   */
  static sidebarRowType(element)
  {
    let className = element?.getAttribute?.('class') ??
        (typeof element?.className === 'string' ? element.className : element?.className?.baseVal) ??
        ''
    let match = String(className).match(/tag-type-(\w+)/i)
    if (!match) {
      return null
    }
    return sidebarClassToType(match[1])
  }

  /**
   * @param {string|null|undefined} href
   * @param {string} param
   * @param {function(string): string} [decodeHtmlEntities]
   * @return {string|null}
   */
  static tagNameFromHrefParam(href, param, decodeHtmlEntities = (value) => value)
  {
    if (!href) {
      return null
    }
    let normalizedHref = String(href).replace(/&amp;/gi, '&')
    let match = normalizedHref.match(new RegExp('[?&]' + param + '=([^&#]*)', 'i'))
    if (!match) {
      return null
    }
    let raw = match[1]
    try {
      raw = decodeURIComponent(raw.replace(/\+/g, ' '))
    } catch (error) {
      /* keep raw */
    }
    let decoded = decodeHtmlEntities(raw).trim()
    return decoded || null
  }

  /**
   * @param {HTMLElement} row
   * @param {{decodeHtmlEntities?: function(string): string, normalizeAtom?: function(string): string}} [options]
   * @return {string|null}
   */
  static sidebarRowTagName(row, options = {})
  {
    let decodeHtmlEntities = options.decodeHtmlEntities ?? ((value) => value)
    let el = row
    if (!el || typeof el.querySelector !== 'function') {
      return null
    }
    let wiki = el.querySelector('a[href*="page=wiki"][href*="search="], a[href*="page=wiki"]')
    let fromWiki = GelbooruFamilyDocument.tagNameFromHrefParam(wiki?.getAttribute('href'), 'search', decodeHtmlEntities)
    if (fromWiki) {
      return fromWiki
    }
    let listLink = el.querySelector('a[href*="tags="]')
    let fromTags = GelbooruFamilyDocument.tagNameFromHrefParam(listLink?.getAttribute('href'), 'tags', decodeHtmlEntities)
    if (fromTags) {
      let first = fromTags.split(/\s+/).filter(Boolean)[0]
      if (first) {
        return first
      }
    }
    let textLink = el.querySelector('a[href*="tags="]') ||
        [...el.querySelectorAll('a')].find((anchor) => {
          let text = (anchor.textContent ?? '').trim()
          return text && text !== '?'
        })
    let text = (textLink?.textContent ?? '').trim()
    if (text && text !== '?') {
      return text.replace(/\s+/g, '_')
    }
    return null
  }

  /**
   * @param {HTMLElement} root
   * @param {{decodeHtmlEntities?: function(string): string, normalizeAtom?: function(string): string, sidebarRowSelector?: string}} [options]
   * @return {{tagGroups: Record<string, string[]>, tagIncidences: Record<string, number>}}
   */
  static getMediaTagGroupsAndIncidencesFromRoot(root, options = {})
  {
    let normalizeAtom = options.normalizeAtom ?? ((tag) => tag)
    let decodeHtmlEntities = options.decodeHtmlEntities ?? ((value) => value)
    let tagGroups = {author: [], copyright: [], character: [], meta: [], general: []}
    let tagIncidences = {}
    let scope = root?.querySelectorAll ? root : document
    let rowSelector = options.sidebarRowSelector ?? '#tag-sidebar li[class*="tag-type-"]'
    for (let element of scope.querySelectorAll(rowSelector)) {
      let type = GelbooruFamilyDocument.sidebarRowType(element)
      let name = normalizeAtom(GelbooruFamilyDocument.sidebarRowTagName(element, {decodeHtmlEntities}) ?? '')
      if (!name || !type || !tagGroups[type]) {
        continue
      }
      tagGroups[type].push(name)
      let countEl = element.querySelector('span.tag-count')
      let raw = countEl?.textContent ?? ''
      if (raw) {
        let count = Number.parseInt(String(raw).replace(/[^\d]/g, ''), 10)
        if (Number.isFinite(count)) {
          tagIncidences[name] = count
        }
      }
    }
    return {tagGroups, tagIncidences}
  }
}

/**
 * @typedef {object} GelbooruFamilyTagTypeResolverOptions
 * @property {GelbooruFamilySearchAdapter} adapter
 * @property {function(): string[]} getDefaultTokens
 * @property {function(): string[]|Iterable<string>} [getManagedMetatagKeys]
 * @property {function(string): string} [normalizeTagAtom]
 * @property {function(string): string} [decodeHtmlEntities]
 * @property {function(): *} [getTagRuntime]
 * @property {function(string): *|null} [getCachedTagEntry]
 * @property {function(*): (string|Promise<string|null>|null)} [resolveCanonicalTypeName]
 * @property {function(string): string} [autocompleteUrl]
 * @property {string} [sidebarRowSelector]
 * @property {function(string): Promise<*>} [pageFetchJson]
 * @property {function(string): Promise<*>} [gmGetJson]
 * @property {number} [cacheMax]
 */

/**
 * Bookmark tag-type resolution for Gelbooru-family sites: sidebar scrape → IDB-warmed registry
 * row → autocomplete API. Exposes a Framework `resolveTagTypes` hook factory.
 */
class GelbooruFamilyTagTypeResolver
{
  /**
   * @param {GelbooruFamilyTagTypeResolverOptions} options
   */
  constructor(options)
  {
    this.adapter = options.adapter
    this.getDefaultTokens = options.getDefaultTokens
    this.getManagedMetatagKeys = options.getManagedMetatagKeys ?? (() => [])
    this.normalizeTagAtom = options.normalizeTagAtom ?? ((value) => String(value ?? '').trim())
    this.decodeHtmlEntities = options.decodeHtmlEntities ?? ((value) => value)
    this.getTagRuntime = options.getTagRuntime ?? (() => null)
    this.getCachedTagEntry = options.getCachedTagEntry ?? (() => null)
    this.resolveCanonicalTypeName = options.resolveCanonicalTypeName ?? (() => null)
    this.autocompleteUrl = options.autocompleteUrl ??
        ((name) => location.origin + '/public/autocomplete.php?q=' + encodeURIComponent(name))
    this.sidebarRowSelector = options.sidebarRowSelector ?? '#tag-sidebar li[class*="tag-type-"]'
    this.pageFetchJson = options.pageFetchJson ?? GelbooruFamilyTagTypeResolver._defaultPageFetchJson
    this.gmGetJson = options.gmGetJson ?? GelbooruFamilyTagTypeResolver._defaultGmGetJson
    this.cacheMax = options.cacheMax ?? 500
    /** @type {Map<string, string|null>} */
    this._cache = new Map()
  }

  /**
   * @param {string} url
   * @return {Promise<*>}
   * @private
   */
  static async _defaultPageFetchJson(url)
  {
    let fetchFn = typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.fetch === 'function' ?
        unsafeWindow.fetch.bind(unsafeWindow) :
        fetch
    let response = await fetchFn(url, {
      credentials: 'include',
      headers: {Accept: 'application/json'},
    })
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`)
    }
    return response.json()
  }

  /**
   * @param {string} url
   * @return {Promise<*>}
   * @private
   */
  static _defaultGmGetJson(url)
  {
    if (typeof GM_xmlhttpRequest !== 'function') {
      return Promise.reject(new Error('GM_xmlhttpRequest unavailable'))
    }
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        headers: {
          Accept: 'application/json',
          Referer: location.href,
          Origin: location.origin,
        },
        anonymous: false,
        onload: (response) => {
          if (response.status >= 200 && response.status < 300) {
            try {
              resolve(JSON.parse(response.responseText))
            } catch (error) {
              reject(error)
            }
            return
          }
          reject(new Error(`HTTP ${response.status}`))
        },
        onerror: (error) => reject(error),
        ontimeout: () => reject(new Error('GM_xmlhttpRequest timeout')),
      })
    })
  }

  /**
   * @return {function(string, {url?: string, normalize?: function(string): string}): Promise<{name: string, type: string}[]>}
   */
  createResolveTagTypesHook()
  {
    return (tags, ctx) => this.resolveTagTypes(tags, ctx)
  }

  /**
   * Framework `resolveTagTypes` hook: tokenize bookmark tags and resolve each canonical type.
   *
   * @param {string} tags
   * @param {{url?: string, normalize?: function(string): string}} [ctx]
   * @return {Promise<{name: string, type: string}[]>}
   */
  async resolveTagTypes(tags, ctx = {})
  {
    let raw = String(tags ?? '').trim()
    if (!raw) {
      return []
    }
    let normalize = ctx.normalize ?? this.normalizeTagAtom
    let managedKeys = this.getManagedMetatagKeys()
    let tokens = this.adapter.extractUserTokens(
        this.adapter.engine.tokenize(raw),
        this.getDefaultTokens(),
        managedKeys,
    )
    let seen = new Set()
    let names = []
    for (let token of tokens) {
      let name = normalize(token)
      if (name && !seen.has(name)) {
        seen.add(name)
        names.push(name)
      }
    }
    let tagRuntime = this.getTagRuntime()
    if (tagRuntime && names.length) {
      await tagRuntime.ensureNames(names)
    }
    let typed = []
    for (let name of names) {
      let type = await this.resolveTagType(name, normalize)
      if (type) {
        typed.push({name, type})
      }
    }
    return typed
  }

  /**
   * Resolve a normalized tag atom: sidebar → registry row → autocomplete API.
   *
   * @param {string} name
   * @param {function(string): string} [normalize]
   * @return {Promise<string|null>}
   */
  async resolveTagType(name, normalize = this.normalizeTagAtom)
  {
    if (!name) {
      return null
    }
    if (this._cache.has(name)) {
      return this._cache.get(name)
    }
    let type = this._resolveFromSidebar(name, normalize) ?? this._resolveFromRegistry(name)
    if (!type) {
      type = await this._resolveFromAutocomplete(name, normalize)
    }
    this._rememberCache(name, type)
    return type
  }

  /**
   * @param {string} name
   * @param {string|null} type
   * @private
   */
  _rememberCache(name, type)
  {
    if (this._cache.size >= this.cacheMax) {
      let oldest = this._cache.keys().next().value
      if (oldest != null) {
        this._cache.delete(oldest)
      }
    }
    this._cache.set(name, type)
  }

  /**
   * @param {string} name
   * @param {function(string): string} normalize
   * @return {string|null}
   * @private
   */
  _resolveFromSidebar(name, normalize)
  {
    for (let row of document.querySelectorAll(this.sidebarRowSelector)) {
      let rowName = GelbooruFamilyDocument.sidebarRowTagName(row, {
        decodeHtmlEntities: this.decodeHtmlEntities,
      })
      if (!rowName || normalize(rowName) !== name) {
        continue
      }
      return GelbooruFamilyDocument.sidebarRowType(row)
    }
    return null
  }

  /**
   * @param {string} name
   * @return {string|null}
   * @private
   */
  _resolveFromRegistry(name)
  {
    let cached = this.getCachedTagEntry(name)
    let typeEntryId = cached?.typeEntryId ?? cached?.meta?.lastSeenTypeEntryId ?? null
    if (typeEntryId == null) {
      return null
    }
    return typeFromEntryId(typeEntryId)
  }

  /**
   * @param {string} name
   * @param {function(string): string} normalize
   * @return {Promise<string|null>}
   * @private
   */
  async _resolveFromAutocomplete(name, normalize)
  {
    try {
      let url = this.autocompleteUrl(name)
      let data = await this._fetchAutocompleteJson(url)
      if (!Array.isArray(data)) {
        return null
      }
      let match = data.find(
          (entry) => normalize(this.decodeHtmlEntities(String(entry?.value ?? ''))) === name)
      if (match?.type == null) {
        return null
      }
      let resolved = this.resolveCanonicalTypeName(match.type)
      return resolved instanceof Promise ? await resolved : resolved
    } catch (error) {
      console.log('[GelbooruFamilyTagTypeResolver] autocomplete tag-type lookup failed:', name, error)
      return null
    }
  }

  /**
   * @param {string} url
   * @return {Promise<*>}
   * @private
   */
  async _fetchAutocompleteJson(url)
  {
    try {
      return await this.pageFetchJson(url)
    } catch (pageError) {
      console.log('[GelbooruFamilyTagTypeResolver] page fetch autocomplete failed, trying GM:', pageError)
      return await this.gmGetJson(url)
    }
  }
}

/**
 * @typedef {object} GelbooruFamilySiteHelperOptions
 * @property {GelbooruFamilySearchAdapter} adapter
 * @property {function(): string[]} getDefaultTokens
 * @property {function(): string[]} [getManagedMetatagKeys]
 * @property {string} [searchInputSelector]
 * @property {string} [tagsReadyClass]
 */

/**
 * Search-page default tag injection + in-place input hook (Gelbooru-family `tags=` param).
 */
class GelbooruFamilySiteHelper
{
  /**
   * @param {GelbooruFamilySiteHelperOptions} options
   */
  constructor(options)
  {
    this.adapter = options.adapter
    this.getDefaultTokens = options.getDefaultTokens
    this.getManagedMetatagKeys = options.getManagedMetatagKeys ?? null
    this.searchInputSelector = options.searchInputSelector ?? '.tag-search input[name="tags"]'
    this.tagsReadyClass = options.tagsReadyClass ?? 'bv-tags-ready'
    this._redirecting = false
    this._searchInputHooked = false
  }

  /**
   * @return {string[]}
   * @private
   */
  _getManagedMetatagKeys()
  {
    if (!this.getManagedMetatagKeys) {
      return []
    }
    let keys = this.getManagedMetatagKeys()
    return Array.isArray(keys) ? keys : []
  }

  /**
   * @param {string} userQuery
   * @return {string[]}
   * @private
   */
  _reconcileSearchTokens(userQuery)
  {
    return this.adapter.reconcileDefaults(
        userQuery,
        this.getDefaultTokens(),
        this._getManagedMetatagKeys(),
    )
  }

  /** @return {boolean} */
  get redirecting()
  {
    return this._redirecting
  }

  /**
   * @param {boolean} isSearchPage
   */
  handleDefaultInjection(isSearchPage)
  {
    if (!isSearchPage) {
      return
    }
    let defaults = this.getDefaultTokens()
    let managedKeys = this._getManagedMetatagKeys()
    if (!defaults.length && !managedKeys.length) {
      return
    }

    let params = new URLSearchParams(location.search)
    let current = this.adapter.normalizeQueryParam(params.get(this.adapter.queryParam) ?? '')
    let normalizedCurrent = this.adapter.engine.tokenize(current).join(' ')
    let merged = this._reconcileSearchTokens(current).join(' ')

    if (merged !== normalizedCurrent) {
      this._redirecting = true
      location.href = this.adapter.buildListUrl(merged)
    }
  }

  refreshSearchInputDisplay()
  {
    let input = document.querySelector(this.searchInputSelector)
    if (!input) {
      return
    }

    let tags = input.value
    if (!tags.trim()) {
      let params = new URLSearchParams(location.search)
      tags = this.adapter.normalizeQueryParam(params.get(this.adapter.queryParam) ?? '')
    } else {
      tags = this.adapter.normalizeQueryParam(tags)
    }

    input.value = this.adapter.extractUserTokens(
        this.adapter.engine.tokenize(tags),
        this.getDefaultTokens(),
        this._getManagedMetatagKeys(),
    ).join(' ')
    input.classList.add(this.tagsReadyClass)
  }

  setupSearchInputHook()
  {
    if (this._redirecting) {
      return
    }
    let input = document.querySelector(this.searchInputSelector)
    if (!input) {
      return
    }

    this.refreshSearchInputDisplay()

    if (!this._searchInputHooked) {
      let form = input.closest('form')
      if (form) {
        form.addEventListener('submit', (event) => {
          event.preventDefault()
          let merged = this._reconcileSearchTokens(input.value).join(' ')
          location.href = this.adapter.buildListUrl(merged)
        }, true)
      }
      this._searchInputHooked = true
    }
  }
}