Brazen Framework - Tag Query Engine

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

このスクリプトは単体で利用できません。右のようなメタデータを含むスクリプトから、ライブラリとして読み込まれます: // @require https://update.greasyfork.org/scripts/583965/1880215/Brazen%20Framework%20-%20Tag%20Query%20Engine.js

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Brazen Framework - Tag Query Engine
// @namespace    brazenvoid
// @version      1.1.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
  // -------------------------------------------------------------------------

  /**
   * @param {string} token
   * @return {string|null}
   */
  static defaultMetatagKey(token)
  {
    let subject = token.startsWith('-') ? token.slice(1) : token
    if (subject.startsWith('(')) {
      return null
    }
    let colon = subject.indexOf(':')
    return colon > 0 ? subject.slice(0, colon) : 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))
  }
}

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

/**
 * @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.
 */
class GelbooruFamilySearchAdapter
{
  // -------------------------------------------------------------------------
  // 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})
  }

  // -------------------------------------------------------------------------
  // 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 colonIdx = tag.indexOf(':')
    if (colonIdx > 0) {
      let key = tag.slice(0, colonIdx)
      if (this.singleInstanceKeys.has(key)) {
        return negated ? '-' + tag : tag
      }
      tag = key + ':' + tag.slice(colonIdx + 1).trim().replace(/\s+/g, '_').toLowerCase()
      return negated ? '-' + tag : tag
    }

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