Brazen Framework - Tag Query Engine

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

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @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 यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला 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
    }
  }
}