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
// ==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(/&/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
}
}
}