NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Rule34.xxx - Search, Download & UI Enhancements
// @namespace brazenvoid
// @version 4.1.0
// @author brazenvoid
// @license GPL-3.0-only
// @description Hoarder friendly bookmarks, filters, single/multi downloads with ledger, tag ignore/substitutions/blacklists
// @match https://rule34.xxx/*
// @require https://update.greasyfork.org/scripts/375557/1882642/Brazen%20Framework%20-%20Utilities.js
// @require https://update.greasyfork.org/scripts/416104/1884336/Brazen%20Framework%20-%20View%20Layer.js
// @require https://update.greasyfork.org/scripts/587030/1884360/Brazen%20Framework%20-%20IndexedDB%20Storage.js
// @require https://update.greasyfork.org/scripts/418665/1884358/Brazen%20Framework%20-%20Configuration%20Manager.js
// @require https://update.greasyfork.org/scripts/583965/1880215/Brazen%20Framework%20-%20Tag%20Query%20Engine.js
// @require https://update.greasyfork.org/scripts/587126/1884339/Brazen%20Framework%20-%20Download%20Manager.js
// @require https://update.greasyfork.org/scripts/416105/1884337/Brazen%20Framework%20-%20Framework.js
// @grant GM_addStyle
// @grant GM_download
// @grant GM_getValue
// @grant GM_deleteValue
// @run-at document-end
// ==/UserScript==
GM_addStyle(
`:root { --bv-theme-color: #2a352a; --bv-dock-bg: var(--bv-theme-color); --bv-dock-btn-bg: #4a5f4a; --bv-dock-btn-active-bg: #778c77; --bv-dock-slideout-bg: rgba(0, 0, 0, 0.28); } #bv-ui.bv-dock-panel-left, #bv-compliance-rules.bv-dock-panel-left, #bv-tag-discovery-panel.bv-dock-panel-left, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download).bv-dock-panel-left, #bv-download-interruption-panel.bv-dock-panel-left { border-bottom-left-radius: 0; border-left: 0; border-radius: 0 8px 8px 0; border-right: 1px solid #1a221a; box-shadow: 2px 0 12px rgba(0, 0, 0, 0.35); border-top-left-radius: 0; } #bv-ui.bv-dock-panel-right, #bv-compliance-rules.bv-dock-panel-right, #bv-tag-discovery-panel.bv-dock-panel-right, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download).bv-dock-panel-right, #bv-download-interruption-panel.bv-dock-panel-right { border-bottom-right-radius: 0; border-radius: 8px 0 0 8px; border-right: 0; box-shadow: -2px 0 12px rgba(0, 0, 0, 0.35); border-top-right-radius: 0; } #bv-ui.bv-dock-panel-bottom, #bv-compliance-rules.bv-dock-panel-bottom, #bv-tag-discovery-panel.bv-dock-panel-bottom, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download).bv-dock-panel-bottom, #bv-download-interruption-panel.bv-dock-panel-bottom { border-bottom-left-radius: 0; border-bottom-right-radius: 0; border-radius: 8px 8px 0 0; box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); max-height: 80vh; top: auto !important; width: min(400px, 96vw); } #bv-ui, #bv-compliance-rules, #bv-tag-discovery-panel, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download), #bv-download-interruption-panel { box-sizing: border-box; display: flex; flex-direction: column; height: auto; max-height: 90vh; min-width: 400px; position: fixed; width: 400px; z-index: 1001; overflow-x: hidden; overflow-y: auto; font-size: 0.8125rem; font-weight: 400; line-height: 1.3; backdrop-filter: none !important; background: var(--bv-theme-color) !important; opacity: 1 !important; border: 1px solid #1a221a; border-right: 0; border-radius: 8px 0 0 8px; padding: 0.5rem 0.7rem 0.6rem !important; } #bv-compliance-rules, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download), #bv-download-interruption-panel { min-width: 300px; padding: 0.5rem 0.7rem 0.6rem !important; width: min(340px, 92vw); } #bv-ui:not(.bv-dock-panel-left):not(.bv-dock-panel-right):not(.bv-dock-panel-bottom), #bv-compliance-rules:not(.bv-dock-panel-left):not(.bv-dock-panel-right):not(.bv-dock-panel-bottom), #bv-tag-discovery-panel:not(.bv-dock-panel-left):not(.bv-dock-panel-right):not(.bv-dock-panel-bottom), :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download):not(.bv-dock-panel-left):not(.bv-dock-panel-right):not(.bv-dock-panel-bottom), #bv-download-interruption-panel:not(.bv-dock-panel-left):not(.bv-dock-panel-right):not(.bv-dock-panel-bottom) { bottom: auto; left: auto; right: 0; top: 5vh; } #bv-ui *, #bv-compliance-rules *, #bv-tag-discovery-panel *, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) *, #bv-download-interruption-panel * { box-sizing: border-box; } #bv-ui:hover { opacity: 1 !important; } #bv-ui.bv-bg-colour { background-color: var(--bv-theme-color) !important; } #bv-ui > div + div { margin-top: 0.4rem !important; } #bv-resizer { align-items: center; background-color: #4a5f4a; border: 1px solid #1a221a; cursor: ew-resize; display: flex; height: 14px; justify-content: center; position: absolute; top: 0.45rem; width: 20px; z-index: 6; } #bv-ui.bv-dock-panel-left #bv-resizer, #bv-ui.bv-dock-panel-bottom #bv-resizer { left: auto; right: 0.4rem; } #bv-ui.bv-dock-panel-right #bv-resizer { left: 0.4rem; right: auto; } #bv-resizer:hover { background-color: #5a6f5a; } #bv-resizer-icon { color: #e8efe8; font-size: 0.7rem; line-height: 1; pointer-events: none; } #bv-ui .bv-tabs-nav { display: flex; flex-wrap: wrap; gap: 2px; margin-bottom: 0.15rem; } #bv-ui .bv-tab-button { color: #c8d4c8; background-color: #1e271e; font-size: 0.75rem; font-weight: 500; margin: 0; padding: 0.22rem 0.42rem; line-height: 1.2; width: auto; } #bv-ui .bv-tab-button.bv-active, #bv-ui .bv-tab-button:hover { background-color: #778c77 !important; color: #fff !important; } #bv-ui .bv-tabs-section { max-width: 100%; min-width: 0; } #bv-ui .bv-tab-panel { background-color: var(--bv-theme-color); border-color: #1a221a !important; box-sizing: border-box; display: none; flex-direction: column; max-width: 100%; min-width: 0; overflow: hidden; padding: 0.5rem 0.65rem !important; gap: 0.3rem; } #bv-ui .bv-tab-panel.bv-active { display: flex; } #bv-ui .bv-group { align-items: center; gap: 0.3rem; max-width: 100%; min-height: 0; min-width: 0; margin: 0; } #bv-ui .bv-group + .bv-group { margin-top: 0.3rem !important; } #bv-ui .bv-group.bv-group-disabled { opacity: 0.45; } #bv-ui .bv-group:has(.bv-input.bv-checkbox-radio), #bv-ui .bv-group.bv-stat-group { align-items: center; column-gap: 0.5rem; display: grid; grid-template-columns: minmax(0, 1fr) auto; } #bv-ui .bv-group:has(.bv-label.bv-text):not(.bv-pattern-group):not(.bv-range-group):not(.bv-textarea-group) { align-items: center; column-gap: 0.5rem; display: grid; grid-template-columns: 6.75rem minmax(8rem, 1fr); max-width: 100%; min-width: 0; } #bv-ui .bv-group.bv-range-group { align-items: center; column-gap: 0.35rem; display: grid; grid-template-columns: 4.5rem minmax(8rem, 1fr); max-width: 100%; min-width: 0; } #bv-ui .bv-group:has(.bv-label.bv-text):not(.bv-pattern-group):not(.bv-range-group):not(.bv-textarea-group) .bv-label.bv-text, #bv-ui .bv-group.bv-range-group .bv-label.bv-text { justify-self: start; min-width: 0; text-align: left; white-space: nowrap; } #bv-ui .bv-group.bv-pattern-group { display: flex; flex-direction: column; align-items: stretch; gap: 0.2rem; } #bv-ui .bv-group.bv-pattern-group > .bv-label.bv-text { display: block; text-align: left; width: 100%; } #bv-ui .bv-group.bv-range-group .bv-label.bv-text { grid-column: 1; grid-row: 1; } #bv-ui .bv-group.bv-range-group .bv-range-inputs { display: flex; gap: 0.35rem; grid-column: 2; grid-row: 1; min-width: 0; width: 100%; } #bv-ui .bv-group.bv-range-group .bv-range-inputs > .bv-input { flex: 1 1 50%; max-width: none; min-width: 3.25rem; width: auto; } #bv-ui .bv-group.bv-textarea-group { display: flex; flex-direction: column; align-items: stretch; gap: 0.2rem; padding: 0 0.05rem; } #bv-ui .bv-group.bv-textarea-group > textarea.bv-input { margin-top: 0; } #bv-ui .bv-substitution-composer { gap: 0.25rem; margin-top: 0; } #bv-ui .bv-substitution-arrow { color: #9eb09e; } #bv-ui .bv-substitution-add { background-color: #4a5f4a; border: 1px solid #3a4f3a; border-radius: 2px; color: #fff; height: 1.65rem; min-width: 1.65rem; width: 1.65rem; } #bv-ui .bv-substitution-add:hover { background-color: #5a6f5a; } #bv-ui .bv-substitution-add-icon { height: 0.95rem; width: 0.95rem; } #bv-ui .bv-group.bv-textarea-group > textarea.bv-input[readonly] { background-color: #a8b5a8; cursor: default; } #bv-ui .bv-label { color: #e8efe8; font-weight: 400; line-height: 1.25; margin: 0; } #bv-ui .bv-group:has(.bv-label.bv-text):not(.bv-pattern-group):not(.bv-range-group) .bv-input.bv-text { justify-self: stretch; max-width: 100%; width: 100%; } #bv-ui .bv-input.bv-checkbox-radio { justify-self: end; margin: 0; scale: 1; } #bv-ui .bv-title { color: #e8efe8; font-size: 0.75rem; font-weight: 500; height: auto; margin: 0 0 0.2rem; min-height: 0; } #bv-ui .bv-stat-label { color: #e8efe8; font-weight: 500; justify-self: end; text-align: right; width: 1.35rem; } #bv-ui .bv-input { background-color: #c5d0c5; color: #1a221a; border: 1px solid #8a9a8a; border-radius: 2px; box-sizing: border-box; font-size: 0.8125rem; font-weight: 400; max-width: 100%; min-width: 0; padding: 0.18rem 0.32rem; } #bv-ui .bv-group.bv-range-group .bv-input { min-height: 1.5rem; min-width: 0; } #bv-ui textarea.bv-input, #bv-ui .bv-group.bv-textarea-group > textarea.bv-input { display: block; max-width: 100%; min-height: 0; padding: 0.28rem 0.35rem; resize: vertical; width: 100%; } #bv-ui .bv-group.bv-pattern-group > .bv-input.bv-text { max-width: 100%; width: 100%; } #bv-ui .bv-input.bv-pattern-active { outline: 1px solid #9eb09e; outline-offset: 1px; } #bv-ui .bv-pattern-builder.bv-pattern-builder-shared { margin-top: 0.15rem; } #bv-ui .bv-button { background-color: #4a5f4a; box-sizing: border-box; color: #fff; border: 1px solid #3a4f3a; border-radius: 2px; font-size: 0.8125rem; font-weight: 500; line-height: 1.25; margin: 0; padding: 0.28rem 0.45rem; } #bv-ui a.bv-button.bv-donate-patreon { align-items: center; display: inline-flex; gap: 0.4rem; justify-content: center; text-decoration: none; width: 100%; } #bv-ui a.bv-button.bv-donate-patreon:hover { background-color: #5a6f5a; color: #fff; } #bv-ui .bv-donate-intro { color: #c8d4c8; font-size: 0.78rem; margin: 0 0 0.45rem; } #bv-ui .bv-donate-patreon-icon { color: #ff424d; height: 1.1rem; width: 1.1rem; } #bv-ui .bv-button:hover { background-color: #5a6f5a; } #bv-ui .bv-section button + button { margin-left: 0 !important; } #bv-ui .bv-actions { align-items: stretch; display: flex; gap: 0.25rem; padding: 0; width: 100%; } #bv-ui .bv-actions:not(.bv-flex-column) { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.3rem; } #bv-ui .bv-actions:not(.bv-flex-column) .bv-button { flex: none; min-width: 0; width: 100%; } #bv-ui .bv-actions.bv-flex-column { flex-direction: column; gap: 0.22rem; } #bv-ui .bv-actions.bv-flex-column .bv-button { width: 100%; } #bv-ui .bv-bottom-section { align-items: stretch; display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.4rem !important; max-width: 100%; min-width: 0; padding: 0 0.1rem; } #bv-ui .bv-bottom-section .bv-group.bv-stat-group { grid-template-columns: minmax(0, 1fr) auto; } #bv-ui hr { border: 0; border-top: 1px solid rgba(0, 0, 0, 0.35); margin: 0.3rem 0 !important; } #bv-ui .bv-pattern-builder { display: flex; flex-direction: column; gap: 0.2rem; grid-column: 1 / -1; margin-top: 0.15rem; } #bv-ui .bv-token-row { align-items: start; display: grid; gap: 0.18rem 0.3rem; grid-template-columns: 2.65rem minmax(0, 1fr); } #bv-ui .bv-token-row-label { color: #9eb09e; font-size: 0.68rem; grid-column: 1; line-height: 1.45; padding-top: 0.14rem; } #bv-ui .bv-token-bar { display: flex; flex-wrap: wrap; gap: 0.18rem; grid-column: 2; min-width: 0; } #bv-ui .bv-token-btn { background: #3d4f3d; border: 1px solid #5a6f5a; border-radius: 2px; box-sizing: border-box; color: #e8efe8; cursor: pointer; font-size: 0.68rem; font-weight: 500; line-height: 1.2; margin: 0; padding: 0.12rem 0.32rem; width: auto; } #bv-ui .bv-token-btn:hover { background: #4f634f; } #bv-ui .bv-token-btn:active, #bv-ui .bv-button:active, #bv-ui .bv-tab-button:active { filter: brightness(1.2); transform: scale(0.98); } #bv-ui .bv-token-btn.bv-token-flash { background: #6a8f6a !important; border-color: #9eb09e !important; } #bv-ui .bv-input.bv-pattern-insert-flash { outline: 2px solid #9eb09e; outline-offset: 1px; } #bv-ui .bv-token-btn.bv-token-sep { background: #334433; min-width: 1.35rem; padding: 0.12rem 0.28rem; text-align: center; } #bv-ui .bv-bookmark-toolbar { margin-top: 0.25rem; } #bv-ui .bv-bookmark-sort { background: #3d4f3d; border: 1px solid #5a6f5a; border-radius: 2px; color: #e8efe8; font-size: 0.8rem; padding: 0; } #bv-ui .bv-bookmark-sort:hover { background: #4f634f; } #bv-ui .bv-bookmark-list { margin-top: 0.25rem; } #bv-ui .bv-bookmark-link { background: #3d4f3d; border: 1px solid #5a6f5a; border-radius: 2px; color: #e8efe8; font-size: 0.75rem; } #bv-ui .bv-bookmark-link:hover { background: #4f634f; } #bv-ui .bv-bookmark-remove { background: #4a3d3d; border: 1px solid #6f5a5a; border-radius: 2px; color: #e8efe8; font-size: 0.95rem; padding: 0; } #bv-ui .bv-bookmark-remove:hover { background: #5a4f4f; } #bv-ui .bv-bookmark-action { background: #3d4f3d; border: 1px solid #5a6f5a; border-radius: 2px; color: #e8efe8; } #bv-ui .bv-bookmark-action:hover { background: #4f634f; } #bv-ui .bv-bookmark-action .r34-tag-action-icon { height: 1rem; width: 1rem; } #bv-ui .bv-bookmark-empty { color: #9eb09e; font-size: 0.72rem; } #bv-ui .bv-bookmark-link:active, #bv-ui .bv-bookmark-remove:active, #bv-ui .bv-bookmark-action:active { filter: brightness(1.2); transform: scale(0.98); } .tag-search h5.r34-bookmark-heading { align-items: center; display: flex; justify-content: space-between; gap: 0.25rem; line-height: 1.5; min-height: 1.5em; } .tag-search .r34-bookmark-star { align-items: center; background: none; border: 0; box-sizing: border-box; color: var(--bv-theme-color); cursor: pointer; display: inline-flex; flex-shrink: 0; height: 1.5em; justify-content: center; line-height: 0; margin: 0; padding: 0; vertical-align: middle; width: 1.5em; } .tag-search .r34-bookmark-star-icon { display: block; height: 100%; width: 100%; } .tag-search .r34-bookmark-star:hover { filter: brightness(1.15); } .tag-search .r34-bookmark-star:active { transform: scale(0.98); } .tag-search input[name="tags"]:not(.bv-tags-ready) { opacity: 0; } #bv-ui, #bv-tag-discovery-panel, #bv-compliance-rules, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download), #bv-download-interruption-panel, #bv-ui :is(button, input, select, textarea, .bv-button, .bv-tab-button, .bv-label, .bv-title, .bv-token-btn, .bv-bookmark-link, .bv-bookmark-sort, .bv-bookmark-remove, .bv-bookmark-action), #bv-tag-discovery-panel :is(button, input, select, textarea, .bv-button, .bv-label, .bv-title, h2, h3, p, span), #bv-compliance-rules :is(button, input, select, textarea, .bv-button, .bv-label, .bv-title, h2, h3, p, span), :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) :is(button, input, select, textarea, .bv-button, .bv-label, .bv-title, h2, h3, p, span), #bv-download-interruption-panel :is(button, input, select, textarea, .bv-button, .bv-label, .bv-title, h2, h3, p, span) { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important; } .bv-modal-dialog, .bv-modal-dialog :is(button, input, select, textarea, h2, h3, p, span) { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important; } #tag-sidebar .bv-tag-actions-slot { display: inline-flex; align-items: center; line-height: 1; min-width: 2.5em; justify-content: flex-end; vertical-align: middle; } #tag-sidebar .bv-tag-actions-slot .tag-count { line-height: 1; vertical-align: middle; } #tag-sidebar .bv-tag-actions-slot .tag-count[hidden] { display: none; } #tag-sidebar .bv-tag-actions { align-items: center; display: inline-flex; gap: 0.1rem; line-height: 1; vertical-align: middle; } #tag-sidebar .bv-tag-actions[hidden] { display: none; } #tag-sidebar li.r34-tag-muted a { color: #8a8a8a !important; } #tag-sidebar li.r34-tag-hidden a { color: #865050 !important; } #tag-sidebar .r34-tag-action-btn { align-items: center; background: none; border: 0; color: var(--bv-theme-color); cursor: pointer; display: inline-flex; flex-shrink: 0; height: 1em; justify-content: center; line-height: 1; margin: 0; padding: 0; vertical-align: middle; width: 1em; } #tag-sidebar .r34-tag-action-btn:hover { filter: brightness(1.15); } #tag-sidebar .r34-tag-action-btn:active { transform: scale(0.95); } #tag-sidebar .r34-tag-action-icon { display: block; height: 100%; width: 100%; } #tag-sidebar .r34-tag-action-icon-letters { text-rendering: geometricPrecision; shape-rendering: geometricPrecision; -webkit-font-smoothing: antialiased; } #bv-tag-discovery-panel.bv-bg-colour, #bv-compliance-rules.bv-bg-colour, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download).bv-bg-colour, #bv-download-interruption-panel.bv-bg-colour { background-color: var(--bv-theme-color) !important; } #bv-tag-discovery-panel .bv-dock-slide-panel-title, #bv-compliance-rules .bv-dock-slide-panel-title, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-slide-panel-title, #bv-download-interruption-panel .bv-dock-slide-panel-title { color: #e8efe8; font-size: 0.75rem; font-weight: 500; } #bv-compliance-rules .bv-dock-slide-panel-close { background-color: #4a5f4a; border: 1px solid #3a4f3a; color: #fff; font-size: 0.8125rem; line-height: 1.1; padding: 0.18rem 0.38rem; } #bv-compliance-rules .bv-dock-slide-panel-close:hover { background-color: #5a6f5a; } #bv-tag-discovery-panel .bv-dock-list-intro, #bv-compliance-rules .bv-dock-list-intro, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-list-intro, #bv-download-interruption-panel .bv-dock-list-intro { color: #c8d4c8; font-size: 0.78rem; } :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) { overflow-y: visible; } :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-slide-panel-body { flex: 0 1 auto; min-height: 0; overflow: visible; } #bv-download-interruption-panel .bv-download-interruption-tip { color: #9eb09e; font-size: 0.72rem; line-height: 1.35; margin: 0.55rem 0 0; } #bv-tag-discovery-panel .bv-dock-list-group h3, #bv-compliance-rules .bv-dock-list-group h3 { color: #9eb09e; font-size: 0.68rem; font-weight: 500; letter-spacing: 0.05em; } #bv-compliance-rules .bv-dock-list-meta { color: #9eb09e; } #bv-tag-discovery-panel .bv-tag-actions, #bv-compliance-rules .bv-tag-actions { align-items: center; display: inline-flex; gap: 0.12rem; line-height: 1; } #bv-compliance-rules .bv-dock-list-action-btn { background-color: #4a5f4a; border: 1px solid #3a4f3a; border-radius: 2px; color: #fff; font-size: 0.72rem; line-height: 1.2; padding: 0.15rem 0.35rem; width: auto; } #bv-compliance-rules .bv-dock-list-action-btn:hover { background-color: #5a6f5a; } #bv-tag-discovery-panel .r34-tag-action-btn { align-items: center; background: none; border: 0; color: #c8d4c8; cursor: pointer; display: inline-flex; flex-shrink: 0; height: 1.1em; justify-content: center; line-height: 1; margin: 0; padding: 0; vertical-align: middle; width: 1.1em; } #bv-tag-discovery-panel .r34-tag-action-btn:hover { color: #fff; filter: brightness(1.1); } #bv-tag-discovery-panel .r34-tag-action-btn:active { transform: scale(0.95); } #bv-tag-discovery-panel .r34-tag-action-icon { display: block; height: 100%; width: 100%; } #bv-tag-discovery-panel .r34-tag-action-icon-letters { text-rendering: geometricPrecision; shape-rendering: geometricPrecision; -webkit-font-smoothing: antialiased; } #bv-tag-discovery-panel .bv-dock-list-footer .bv-button, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-list-footer .bv-button, #bv-download-interruption-panel .bv-dock-list-footer .bv-button { background-color: #4a5f4a; border: 1px solid #3a4f3a; border-radius: 2px; box-sizing: border-box; color: #fff; font-size: 0.8125rem; font-weight: 500; line-height: 1.25; margin: 0; padding: 0.28rem 0.45rem; width: 100%; } #bv-tag-discovery-panel .bv-dock-list-footer.bv-dock-list-footer-row .bv-button { flex: 1 1 0; min-width: 0; width: auto; } #bv-tag-discovery-panel .bv-dock-list-footer .bv-button:hover, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-list-footer .bv-button:hover, #bv-download-interruption-panel .bv-dock-list-footer .bv-button:hover { background-color: #5a6f5a; } #bv-tag-discovery-panel .bv-dock-list-footer .bv-dock-list-footer-secondary, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-list-footer .bv-dock-list-footer-secondary { background-color: transparent; border: 1px solid #5a6f5a; color: #c8d4c8; } #bv-tag-discovery-panel .bv-dock-list-footer .bv-dock-list-footer-secondary:hover, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-list-footer .bv-dock-list-footer-secondary:hover { background-color: #3d4f3d; color: #fff; } #bv-tag-discovery-panel .bv-dock-list-section-title { color: #e8efe8; font-size: 0.78rem; font-weight: 600; } #bv-tag-discovery-panel .bv-dock-list-meta { color: #9eb09e; flex: 0 0 auto; font-size: 0.72rem; line-height: 1.3; margin-left: 0.15rem; margin-right: 0.15rem; white-space: nowrap; } .bv-timed-button-container { display: block; position: relative; width: 100%; } .bv-timed-button-container .bv-timed-button { display: block; width: 100%; } .bv-timed-button-bar { background: rgba(0, 0, 0, 0.35); border-radius: 0 0 2px 2px; bottom: 0; height: 2px; left: 0; overflow: hidden; pointer-events: none; position: absolute; right: 0; width: 100%; } .bv-timed-button-bar-fill { background: #9eb09e; height: 100%; width: 100%; } #bv-ui.bv-dock-panel-bottom-half-left, #bv-compliance-rules.bv-dock-panel-bottom-half-left, #bv-tag-discovery-panel.bv-dock-panel-bottom-half-left, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download).bv-dock-panel-bottom-half-left, #bv-download-interruption-panel.bv-dock-panel-bottom-half-left, #bv-ui.bv-dock-panel-bottom-half-right, #bv-compliance-rules.bv-dock-panel-bottom-half-right, #bv-tag-discovery-panel.bv-dock-panel-bottom-half-right, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download).bv-dock-panel-bottom-half-right, #bv-download-interruption-panel.bv-dock-panel-bottom-half-right { min-width: 0 !important; width: auto !important; max-width: 50vw; } body.r34-dm-selection span.thumb, body.r34-dm-selection span.thumb * { -webkit-user-drag: none !important; }`
)
// ---------------------------------------------------------------------------
// Shared URL helpers
// ---------------------------------------------------------------------------
const QUERY_PARAMS = new URLSearchParams(location.search)
const RATE_LIMIT_REFRESH_DELAY_MS = 5000
const RESOLUTION_INITIATION_GAP_MS = 2000
const DOWNLOAD_INITIATION_GAP_MS = 1000
/** Debug: keep dock migrating chrome after init. Leave false in normal use. */
const FORCE_DOCK_MIGRATION_STATUS = false
// ---------------------------------------------------------------------------
// DOM selectors (see rule34xxx.spec.md for the full analysis)
// ---------------------------------------------------------------------------
const SELECTOR_ITEM_LIST = '.image-list'
const SELECTOR_ITEM = 'span.thumb'
const SELECTOR_ITEM_LINK = 'a'
const SELECTOR_ITEM_THUMBNAIL = 'img.preview'
const SELECTOR_MEDIA_PICTURE = '#image'
const SELECTOR_MEDIA_VIDEO_SOURCE = '#gelcomVideoPlayer source'
const SELECTOR_MEDIA_VIDEO_CONTAINER = '#gelcomVideoContainer'
const SELECTOR_STATS = '#stats'
const SELECTOR_SCORE = '#stats span[id^="psc"]'
const SELECTOR_TAG_SIDEBAR = '#tag-sidebar'
const SELECTOR_TAG_SIDEBAR_ROW = '#tag-sidebar li[class*="tag-type-"]'
const SELECTOR_SEARCH_INPUT = '.tag-search input[name="tags"]'
const SELECTOR_SEARCH_HEADING = '.tag-search h5'
const SELECTOR_PAGINATOR_NEXT = '#paginator a[alt="next"]'
const SELECTOR_PAGINATOR_BACK = '#paginator a[alt="back"]'
const BOOKMARK_STAR_ICON_CLASS = 'r34-bookmark-star-icon'
// ---------------------------------------------------------------------------
// Item attributes
// ---------------------------------------------------------------------------
const ITEM_TAGS = 'tags'
const ITEM_POST_ID = 'postId'
// ---------------------------------------------------------------------------
// Configuration keys
// ---------------------------------------------------------------------------
const OPTION_ENABLE_DEFAULT_TAGS = 'enable-default-tags'
const DEFAULTS_TAGS = 'default-tags'
const OPTION_ENABLE_RESOLUTION = 'enable-resolution-filter'
const DEFAULTS_WIDTH = 'width'
const DEFAULTS_HEIGHT = 'height'
const OPTION_ENABLE_EXPLORED_TAGS_TRACKER = 'enable-explored-tags-tracker'
const FILTER_EXPLORED_TAGS = 'explored-tags-tracker'
const OPTION_HIDE_OLDER_POSTS = 'hide-older-posts'
const LAST_ID = 'last-id'
const MEDIA_AUTO_DOWNLOAD = 'auto-download-media'
const MEDIA_REMOVE_ON_SUCCESS = 'remove-media-on-download-success'
const DOWNLOAD_FOLDER = 'download-folder'
const DOWNLOAD_FILENAME_PATTERN = 'filename-pattern'
const DOWNLOAD_SUBFOLDER_PATTERN = 'subfolder-pattern'
const DEFAULT_DOWNLOAD_FOLDER = 'brazen-rule34xxx'
const BOOKMARKS = 'bookmarks'
const DOWNLOAD_TAG_IGNORE = 'filename-tag-ignore-list'
const DOWNLOAD_TAG_SUBSTITUTIONS = 'filename-tag-substitutions'
const DOWNLOAD_STRIP_CHARACTER_SERIES = 'strip-series-from-character-tags'
/** Joins multiple tags of the same type inside a filename/subfolder token. */
const DOWNLOAD_MULTI_TAG_SEPARATOR = ', '
/** Fallback when Artists / Series / Characters tokens are used but the post has none. */
const DOWNLOAD_TAG_TOKEN_UNKNOWN_DEFAULT = 'unknown'
const DOWNLOAD_TAG_TOKEN_UNKNOWN_TYPES = new Set(['author', 'copyright', 'character'])
const SEARCH_AUTO_NEXT = 'auto-next-page'
const DOCK_BOOKMARK_SEARCH = 'bookmark-search'
const DOCK_DOWNLOAD_MEDIA = 'download-media'
const DOCK_SET_LATEST_ID = 'set-latest-id'
const DOCK_SET_POST_ID = 'set-post-id'
/** Download URL captured when the live picture is suppressed (never assigned to a DOM node). */
let _capturedMediaUrl = ''
/** Stub element for metadata after suppression — src is read-only and does not fetch. */
let _capturedMediaElement = null
/** Parent node #image was removed from — used to restore after a failed download. */
let _mediaPictureHost = null
/** Watches for site JS re-inserting #image after suppression. */
let _mediaResurrectionObserver = null
/** Timeout id for the bounded media-resurrection watcher lifetime. */
let _mediaResurrectionTimeoutId = null
/**
* @private
*/
function _clearMediaResurrectionTimeout()
{
if (_mediaResurrectionTimeoutId != null) {
clearTimeout(_mediaResurrectionTimeoutId)
_mediaResurrectionTimeoutId = null
}
}
/**
* @private
*/
function _stopMediaPictureResurrectionWatcher()
{
_clearMediaResurrectionTimeout()
_mediaResurrectionObserver?.disconnect()
_mediaResurrectionObserver = null
}
/**
* Stops the resurrection watcher and drops capture refs without restoring #image.
* Used after a successful remove-on-download suppress (or page unload).
*
* @private
*/
function _releaseSuppressedMediaCapture()
{
_stopMediaPictureResurrectionWatcher()
_capturedMediaUrl = ''
_capturedMediaElement = null
_mediaPictureHost = null
}
/**
* Puts #image back after a failed download when suppression ran for a new save attempt.
*
* @private
*/
function _restoreMediaPicture()
{
_stopMediaPictureResurrectionWatcher()
let url = _capturedMediaUrl
let host = _mediaPictureHost
_capturedMediaUrl = ''
_capturedMediaElement = null
_mediaPictureHost = null
if (!url || !host || document.querySelector(SELECTOR_MEDIA_PICTURE)) {
return
}
let img = document.createElement('img')
img.id = 'image'
img.src = url
host.appendChild(img)
}
/**
* @param {string} candidate
* @param {string} current
* @return {boolean}
*/
function _isPreferredMediaUrl(candidate, current)
{
if (!current) {
return true
}
let candidateIsSample = /sample/i.test(candidate)
let currentIsSample = /sample/i.test(current)
if (candidateIsSample !== currentIsSample) {
return !candidateIsSample
}
return candidate.length >= current.length
}
/**
* Full-resolution media URL from Options → "Original image" (works for pictures and videos).
* Always present on media pages; `#image` / video `source` are resized renditions and are not used.
*
* @param {ParentNode|Document|null|undefined} doc
* @return {string}
*/
function _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 ''
}
/**
* @return {HTMLImageElement|null}
*/
function _getCapturedMediaElement()
{
if (!_capturedMediaUrl) {
return null
}
if (!_capturedMediaElement) {
_capturedMediaElement = document.createElement('img')
Object.defineProperty(_capturedMediaElement, 'src', {
get: () => _capturedMediaUrl,
set: () => {},
configurable: true,
})
}
return _capturedMediaElement
}
/**
* Clears src/srcset and removes a live picture node.
*
* @param {HTMLImageElement} img
*/
function _suppressLiveMediaPicture(img)
{
if (!(img instanceof HTMLImageElement)) {
return
}
if (!_mediaPictureHost && img.parentElement) {
_mediaPictureHost = img.parentElement
}
img.removeAttribute('srcset')
img.removeAttribute('sizes')
img.src = ''
img.remove()
}
/**
* Suppresses a live #image and records its URL without triggering a fetch.
*
* @param {HTMLImageElement} img
*/
function _captureAndSuppressMediaPicture(img)
{
if (!(img instanceof HTMLImageElement)) {
return
}
let url = img.src ?? ''
_suppressLiveMediaPicture(img)
if (!url) {
return
}
if (_isPreferredMediaUrl(url, _capturedMediaUrl)) {
_capturedMediaUrl = url
}
}
/**
* Keeps suppressing #image if the site re-adds it after removal.
* Observes the picture host (not the whole document) to limit mutation cost.
*
* @private
*/
function _watchForMediaPictureResurrection()
{
if (_mediaResurrectionObserver) {
return
}
let root = _mediaPictureHost || document.documentElement
_mediaResurrectionObserver = new MutationObserver((mutations) => {
for (let mutation of mutations) {
if (mutation.type === 'attributes' && mutation.target instanceof HTMLImageElement && mutation.target.id === 'image') {
_captureAndSuppressMediaPicture(mutation.target)
continue
}
for (let node of mutation.addedNodes) {
if (!(node instanceof Element)) {
continue
}
let candidate = node.id === 'image' ? node : node.querySelector('#image')
if (candidate instanceof HTMLImageElement) {
_captureAndSuppressMediaPicture(candidate)
}
}
}
})
_mediaResurrectionObserver.observe(root, {childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'srcset']})
}
/**
* @param {HTMLImageElement} img
*/
function _suppressMediaForDownload(img)
{
_captureAndSuppressMediaPicture(img)
_watchForMediaPictureResurrection()
// Tampermonkey often never fires GM_download onload — drop the watcher after the
// site's typical re-insert window so overnight media tabs do not keep a MutationObserver.
_clearMediaResurrectionTimeout()
_mediaResurrectionTimeoutId = setTimeout(() => {
_mediaResurrectionTimeoutId = null
if (_mediaResurrectionObserver) {
_releaseSuppressedMediaCapture()
}
}, 30_000)
}
/**
* Stops in-page video playback and removes the player container so the browser does not
* keep buffering alongside `GM_download`.
*
* @param {HTMLSourceElement} source
*/
function _suppressVideoForDownload(source)
{
if (!(source instanceof HTMLSourceElement)) {
return
}
let url = source.src ?? ''
if (url && _isPreferredMediaUrl(url, _capturedMediaUrl)) {
_capturedMediaUrl = url
}
let video = document.querySelector('#gelcomVideoPlayer')
if (video instanceof HTMLVideoElement) {
try {
video.pause()
} catch {
}
video.removeAttribute('src')
}
source.removeAttribute('src')
document.querySelector(SELECTOR_MEDIA_VIDEO_CONTAINER)?.remove()
}
// ---------------------------------------------------------------------------
// Tag types
// ---------------------------------------------------------------------------
// Maps the rule34 sidebar CSS class suffix to the canonical type used in rules and filename tokens.
const SIDEBAR_CLASS_TO_TYPE = {
artist: 'author',
copyright: 'copyright',
character: 'character',
metadata: 'meta',
general: 'general',
}
const TYPE_TO_SIDEBAR_CLASS = {
author: 'tag-type-artist',
copyright: 'tag-type-copyright',
character: 'tag-type-character',
meta: 'tag-type-metadata',
general: 'tag-type-general',
}
/** Per-type discovery panel presentation (label, row class, name color) — consumer-owned. */
const TAG_DISCOVERY_TYPES = {
author: {label: 'Artists', rowClass: TYPE_TO_SIDEBAR_CLASS.author, color: '#A00'},
copyright: {label: 'Series', rowClass: TYPE_TO_SIDEBAR_CLASS.copyright, color: '#A0A'},
character: {label: 'Characters', rowClass: TYPE_TO_SIDEBAR_CLASS.character, color: '#0A0'},
meta: {label: 'Meta', rowClass: TYPE_TO_SIDEBAR_CLASS.meta, color: '#F80'},
general: {label: 'General', rowClass: TYPE_TO_SIDEBAR_CLASS.general, color: '#009'},
unknown: {label: 'Other', rowClass: TYPE_TO_SIDEBAR_CLASS.general, color: '#009'},
}
/** Seeded tagTypes entryId → canonical type name (includes artist alias → author). */
const TYPE_ENTRY_ID_TO_TYPE = {
2: 'author',
3: 'copyright',
4: 'character',
5: 'meta',
6: 'general',
7: 'author',
}
const TAG_TYPES = ['author', 'copyright', 'character', 'meta', 'general']
// Chip label text is what users see and what gets inserted into patterns; token keys drive resolution.
const DOWNLOAD_PATTERN_SECTIONS = [
{
label: 'Post',
chips: [
{token: 'id', label: 'ID', title: 'Numeric id of the post'},
{token: 'md5', label: 'File hash', title: 'MD5 hash from the media file name'},
{token: 'width', label: 'Width', title: 'Width in pixels'},
{token: 'height', label: 'Height', title: 'Height in pixels'},
{token: 'score', label: 'Score', title: 'Post score'},
{token: 'rating', label: 'Rating', title: 'Content rating'},
],
},
{
label: 'Tags',
chips: [
{token: 'author', label: 'Artists', title: 'Artist tags, comma-separated and alphabetically sorted'},
{token: 'copyright', label: 'Series', title: 'Copyright / series tags, comma-separated and alphabetically sorted'},
{token: 'character', label: 'Characters', title: 'Character tags, comma-separated and alphabetically sorted'},
{token: 'meta', label: 'Meta', title: 'Metadata tags, comma-separated and alphabetically sorted'},
{token: 'general', label: 'General', title: 'General tags, comma-separated and alphabetically sorted'},
],
},
]
const DOWNLOAD_PATTERN_SEPARATORS = [
{insert: ' ', label: 'Space', title: 'Insert a space between parts'},
{insert: ' - ', label: '–', title: 'Insert a dash between parts'},
{insert: '_', label: '_', title: 'Insert an underscore'},
{insert: '/', label: '/', title: 'Start a new sub-folder level'},
]
/** Gelbooru-family search read/write (rule34 `tags=` param). */
const SEARCH_ADAPTER = new GelbooruFamilySearchAdapter()
class Rule34xxxSearchEnhancer extends BrazenFramework
{
/**
* Set when a default-tags redirect is about to navigate away.
* @type {boolean}
* @private
*/
_redirecting = false
/**
* @type {boolean}
* @private
*/
_searchInputHooked = false
/**
* Memoized default search tokens (invalidated when the settings fingerprint changes).
* @type {string[]|null}
* @private
*/
_defaultTokensCache = null
/**
* @type {string|null}
* @private
*/
_defaultTokensFingerprint = null
/**
* Debounce timer for Hide Downloaded focus/visibility compliance.
* @type {ReturnType<typeof setTimeout>|null}
* @private
*/
_hideDownloadedResyncTimer = null
/**
* One-shot cache so async extractTagGroups + extractTagIncidences share one parse.
* @type {{tagGroups: {}, tagIncidences: Record<string, number>}|null}
* @private
*/
_pendingMediaTagExtract = null
/**
* Live `tagDiscovery` config object shared with Download Manager (mutable `tagTypes`).
* @type {object|null}
* @private
*/
_tagDiscoveryConfig = null
/**
* @type {((event: DragEvent) => void)|null}
* @private
*/
_selectionDragStartHandler = null
/**
* @type {((event: MouseEvent) => void)|null}
* @private
*/
_panelResizerMousedownHandler = null
/**
* Generation token so overlapping `_setupTagSidebarActions` runs discard stale paints.
* @type {number}
* @private
*/
_tagSidebarActionsGeneration = 0
constructor()
{
super({
downloadDuplicateLedger: {
primaryField: 'postIds',
isValidId: (value) => /^\d+$/.test(String(value).trim()),
getDownloadId: (mediaElement) => {
let id = QUERY_PARAMS.get('id') ?? ''
return id.trim() || null
},
},
isUserLoggedIn: false,
itemDeepAnalysisSelector: SELECTOR_TAG_SIDEBAR,
itemLinkSelector: SELECTOR_ITEM_LINK,
itemListSelectors: SELECTOR_ITEM_LIST,
itemNameSelector: '',
itemSelectors: SELECTOR_ITEM,
itemSelectionMethod: 'children',
requestDelay: 0,
scriptPrefix: 'brazen-r34xxx-',
legacyScriptPrefix: 'rule34xxx-',
tagSelectorGenerator: (tag) => SELECTOR_TAG_SIDEBAR + ' li.tag:has(a[href$="search=' + tag.trim().replaceAll(' ', '_') + '"])',
trackComplianceRules: true,
})
this.definePages({
home: () => !!document.getElementById('static-index')
|| (!QUERY_PARAMS.get('page')
&& /^\/(?:index\.php)?$/i.test(location.pathname)),
search: () => QUERY_PARAMS.get('page') === 'post' && QUERY_PARAMS.get('s') === 'list',
media: () => QUERY_PARAMS.get('page') === 'post' && QUERY_PARAMS.get('s') === 'view'
&& !this._isMediaRateLimited()
&& !this._isCloudflareChallengeDoc(document),
mediaRateLimited: () => QUERY_PARAMS.get('page') === 'post' && QUERY_PARAMS.get('s') === 'view' && this._isMediaRateLimited(),
mediaCloudflare: () => QUERY_PARAMS.get('page') === 'post' && QUERY_PARAMS.get('s') === 'view'
&& !this._isMediaRateLimited()
&& this._isCloudflareChallengeDoc(document),
})
this.addPageOperation('mediaRateLimited', () => {
setTimeout(() => location.reload(), RATE_LIMIT_REFRESH_DELAY_MS)
return true
})
this.addPageOperation('mediaCloudflare', () => {
void this.handleMediaCloudflarePage()
return true
})
this.setCompliancePages(['search'])
this._configurationManager.setScriptSetup(rule34xxxSetupStorage)
this._setupFeatures()
this._setupFilters()
this._setupDock()
this._onBeforeFullInit.push(() => {
this._setupUI()
this._setupEvents()
})
}
/**
* @return {boolean}
* @private
*/
_isMediaRateLimited()
{
return this._is429RateLimitDoc(document)
}
/**
* @param {Document} doc
* @return {boolean}
* @private
*/
_hasResolvableMediaDoc(doc)
{
return !!(doc.querySelector('#image') || doc.querySelector('#gelcomVideoPlayer'))
}
/**
* Site 429 HTML swap (auto-retry after {@link RATE_LIMIT_REFRESH_DELAY_MS}).
* @param {Document} doc
* @return {boolean}
* @private
*/
_is429RateLimitDoc(doc)
{
if (this._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')
}
/**
* Cloudflare managed challenge / Turnstile interstitial (human verification required).
* Matches {@link site-html/cloudflare-429-media.html} (site CAPTCHA skin + `_cf_chl_opt` /
* challenge-platform) as well as generic Cloudflare interstitials. Must not match a normal
* media page that only embeds comment Turnstile / bot mgmt scripts while `#image` is present.
* @param {Document} doc
* @return {boolean}
* @private
*/
_isCloudflareChallengeDoc(doc)
{
if (this._hasResolvableMediaDoc(doc)) {
return false
}
if (this._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)
}
/**
* @return {boolean}
* @private
*/
_isVideoMedia()
{
return this.isPage('media') && document.getElementById('gelcomVideoPlayer') != null
}
/**
* @return {boolean}
* @private
*/
_isPictureMedia()
{
return this.isPage('media') && !this._isVideoMedia()
}
// -------------------------------------------------------------------------
// Sidebar tag typing (download filename tokens)
// -------------------------------------------------------------------------
/**
* @param {HTMLElement} element
* @return {string|null}
* @private
*/
_sidebarRowType(element)
{
// Prefer getAttribute — className can be non-string on some parsed nodes.
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 SIDEBAR_CLASS_TO_TYPE[match[1].toLowerCase()] ?? 'general'
}
/**
* Decode a single query-param value from an href (handles `&` and %XX).
* @param {string|null|undefined} href
* @param {string} param
* @return {string|null}
* @private
*/
_tagNameFromHrefParam(href, param)
{
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 = this._decodeHtmlEntities(raw).trim()
return decoded || null
}
/**
* Reads the canonical underscored tag name from a sidebar row.
* Prefers wiki `search=`, then post-list `tags=`, then visible link text
* (DOMParser / partial pages sometimes omit the wiki `?` link).
*
* @param {HTMLElement} row
* @return {string|null}
* @private
*/
_sidebarRowTagName(row)
{
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 = this._tagNameFromHrefParam(wiki?.getAttribute('href'), 'search')
if (fromWiki) {
return fromWiki
}
let listLink = el.querySelector('a[href*="tags="]')
let fromTags = this._tagNameFromHrefParam(listLink?.getAttribute('href'), 'tags')
if (fromTags) {
// List links can be multi-tag; take the first token.
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
}
/**
* Returns bare tag names from a search thumbnail's alt text for blacklist matching.
*
* @param {HTMLElement} item
* @return {string[]}
* @private
*/
_extractItemTags(item)
{
let alt = item.querySelector(SELECTOR_ITEM_THUMBNAIL)?.getAttribute('alt')
if (!alt) {
return []
}
let tags = []
for (let rawName of alt.trim().split(/\s+/)) {
let name = this._normalizeSimpleTagLine(this._decodeHtmlEntities(rawName))
if (name) {
tags.push(name)
}
}
return tags
}
// -------------------------------------------------------------------------
// Default tags & resolution -> metatags
// -------------------------------------------------------------------------
/**
* Collects the enabled default tokens from the default-tags ruleset and the
* resolution range filter.
*
* @return {string[]}
* @private
*/
_getDefaultTokens()
{
let fingerprint = JSON.stringify([
this._getConfig(OPTION_ENABLE_DEFAULT_TAGS),
this._getConfig(DEFAULTS_TAGS),
this._getConfig(OPTION_ENABLE_RESOLUTION),
this._getConfig(DEFAULTS_WIDTH),
this._getConfig(DEFAULTS_HEIGHT),
])
if (this._defaultTokensCache && this._defaultTokensFingerprint === fingerprint) {
return this._defaultTokensCache
}
let tokens = []
if (this._getConfig(OPTION_ENABLE_DEFAULT_TAGS)) {
for (let line of this._getConfig(DEFAULTS_TAGS)) {
tokens.push(...SEARCH_ADAPTER.engine.tokenize(line))
}
}
if (this._getConfig(OPTION_ENABLE_RESOLUTION)) {
this._appendRangeTokens(tokens, 'width', this._getConfig(DEFAULTS_WIDTH))
this._appendRangeTokens(tokens, 'height', this._getConfig(DEFAULTS_HEIGHT))
}
this._defaultTokensCache = tokens
this._defaultTokensFingerprint = fingerprint
return tokens
}
/**
* @param {string[]} tokens
* @param {string} key
* @param {{minimum: *, maximum: *}} range
* @private
*/
_appendRangeTokens(tokens, key, range)
{
let minimum = Number.parseInt(range.minimum) || 0
let maximum = Number.parseInt(range.maximum) || 0
if (minimum > 0) {
tokens.push(key + ':>=' + minimum)
}
if (maximum > 0) {
tokens.push(key + ':<=' + maximum)
}
}
/**
* @param {string|null} tags
* @return {string}
* @private
*/
_normalizeSearchTagsParam(tags)
{
return SEARCH_ADAPTER.normalizeQueryParam(tags)
}
/**
* @param {string} tags
* @return {string}
* @private
*/
_buildSearchListUrl(tags)
{
return SEARCH_ADAPTER.buildListUrl(tags)
}
/**
* On search pages, merges default tokens into the URL `tags` param and
* redirects once if the query changed. The merge is idempotent, which is the
* loop guard.
*
* @private
*/
_handleDefaultInjection()
{
if (!this.isPage('search')) {
return
}
let defaults = this._getDefaultTokens()
if (!defaults.length) {
return
}
let params = new URLSearchParams(location.search)
let current = this._normalizeSearchTagsParam(params.get('tags') ?? '')
let normalizedCurrent = SEARCH_ADAPTER.engine.tokenize(current).join(' ')
let merged = SEARCH_ADAPTER.engine.merge(current, defaults).join(' ')
if (merged !== normalizedCurrent) {
this._redirecting = true
location.href = this._buildSearchListUrl(merged)
}
}
/**
* @private
*/
_refreshSearchInputDisplay()
{
let input = document.querySelector(SELECTOR_SEARCH_INPUT)
if (!input) {
return
}
let tags = input.value
if (!tags.trim()) {
let params = new URLSearchParams(location.search)
tags = this._normalizeSearchTagsParam(params.get('tags') ?? '')
} else {
tags = this._normalizeSearchTagsParam(tags)
}
input.value = SEARCH_ADAPTER.engine.subtract(SEARCH_ADAPTER.engine.tokenize(tags), this._getDefaultTokens()).join(' ')
input.classList.add('bv-tags-ready')
}
/**
* Hooks the real search input so the user only sees their own tags while the
* defaults are merged back in on submit. Operates in place to keep the site's
* awesomplete autocomplete intact.
*
* @private
*/
_setupSearchInputHook()
{
if (this._redirecting) {
return
}
let input = document.querySelector(SELECTOR_SEARCH_INPUT)
if (!input) {
return
}
this._refreshSearchInputDisplay()
if (!this._searchInputHooked) {
let form = input.closest('form')
if (form) {
form.addEventListener('submit', (event) => {
event.preventDefault()
let merged = SEARCH_ADAPTER.engine.merge(input.value, this._getDefaultTokens()).join(' ')
location.href = this._buildSearchListUrl(merged)
}, true)
}
this._searchInputHooked = true
}
}
// -------------------------------------------------------------------------
// Latest only mode
// -------------------------------------------------------------------------
/**
* @param {HTMLElement} item
* @return {number|null}
* @private
*/
_getItemPostId(item)
{
let thumbId = item.getAttribute('id')
if (thumbId && thumbId.startsWith('s')) {
let id = Number.parseInt(thumbId.slice(1), 10)
if (!Number.isNaN(id)) {
return id
}
}
let link = item.querySelector(SELECTOR_ITEM_LINK)
if (!link) {
return null
}
let linkId = link.getAttribute('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
}
/**
* @param {number} value
* @private
*/
_setLatestMarker(value)
{
let field = this._configurationManager.getField(LAST_ID)
field.value = value
if (field.element) {
field.updateUserInterface()
}
this._configurationManager.save()
}
/**
* @private
*/
_handleSetMarkerFromSearch()
{
let maximum = 0
for (let element of document.querySelectorAll(SELECTOR_ITEM)) {
let id = Number.parseInt((element.getAttribute('id') ?? '').slice(1)) || 0
if (id > maximum) {
maximum = id
}
}
if (maximum > 0) {
this._setLatestMarker(maximum)
}
}
/**
* @private
*/
_handleSetMarkerFromMedia()
{
let id = Number.parseInt(QUERY_PARAMS.get('id')) || 0
if (id > 0) {
this._setLatestMarker(id)
}
}
// -------------------------------------------------------------------------
// Media downloads
// -------------------------------------------------------------------------
/**
* @return {HTMLElement|null}
* @private
*/
_getMediaElement()
{
let captured = _getCapturedMediaElement()
if (captured) {
return captured
}
return document.querySelector(this._isVideoMedia() ? SELECTOR_MEDIA_VIDEO_SOURCE : SELECTOR_MEDIA_PICTURE)
}
/**
* Extracts download metadata from the media page DOM and URL (no page globals).
*
* @param {HTMLElement} media
* @return {{}}
* @private
*/
_getMediaData(media)
{
void media
let url = _getOriginalMediaUrlFromDocument(document)
let fileName = url.split('?')[0].split('/').pop()
let extension = fileName.includes('.') ? fileName.split('.').pop() : ''
let md5 = (extension ? fileName.slice(0, -(extension.length + 1)) : fileName).replace(/^sample_/, '')
let data = {
id: QUERY_PARAMS.get('id') ?? '',
md5: md5,
ext: extension,
width: '',
height: '',
score: this._decodeHtmlEntities(document.querySelector(SELECTOR_SCORE)?.textContent?.trim() ?? ''),
rating: '',
source: '',
sourcedomain: '',
}
for (let element of document.querySelectorAll(`${SELECTOR_STATS} li`)) {
let text = this._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]
} else if (/^Rating:/i.test(text)) {
data.rating = text.replace(/^Rating:\s*/i, '').trim()
} else if (/^Source:/i.test(text)) {
let link = element.querySelector('a')
let source = link ? (link.getAttribute('href') ?? link.textContent.trim()) : text.replace(/^Source:\s*/i, '').trim()
data.source = this._decodeHtmlEntities(source)
if (data.source) {
try {
data.sourcedomain = new URL(data.source).hostname
} catch {
data.sourcedomain = ''
}
}
}
}
return data
}
/**
* One extract pipeline for queue + immediate: typed sidebar rows from the media
* document only (the resolve fetch). No extra same-origin tag-list requests.
*
* @return {Promise<{}}
* @private
*/
async _getMediaTagGroupsAsync()
{
this._pendingMediaTagExtract = await this._getMediaTagGroupsAndIncidencesAsync(document)
return this._pendingMediaTagExtract.tagGroups
}
/**
* @return {Promise<Record<string, number>>}
* @private
*/
async _getMediaTagIncidencesAsync()
{
let result = this._pendingMediaTagExtract ??
await this._getMediaTagGroupsAndIncidencesAsync(document)
this._pendingMediaTagExtract = null
return result.tagIncidences
}
/**
* Parse typed tags + incidences from `#tag-sidebar` on the media document.
* @param {Document|ParentNode} root
* @return {{tagGroups: {}, tagIncidences: Record<string, number>}}
* @private
*/
_getMediaTagGroupsAndIncidencesFromRoot(root)
{
let tagGroups = {author: [], copyright: [], character: [], meta: [], general: []}
let tagIncidences = {}
let scope = root?.querySelectorAll ? root : document
let rows = scope.querySelectorAll(SELECTOR_TAG_SIDEBAR_ROW)
rows.forEach((element) => {
let type = this._sidebarRowType(element)
// Native DOM — avoid jQuery find() on DOMParser documents from queue resolution.
let name = this._normalizeTagAtom(this._sidebarRowTagName(element) ?? '')
if (!name || !type || !tagGroups[type]) {
return
}
tagGroups[type].push(name)
let countEl = element.querySelector('span.tag-count')
let raw = countEl?.textContent ?? ''
if (raw) {
let count = parseInt(String(raw).replace(/[^\d]/g, ''), 10)
if (Number.isFinite(count)) {
tagIncidences[name] = count
}
}
})
return {tagGroups, tagIncidences}
}
/**
* Full extract from the already-fetched media document (sidebar only).
*
* @param {Document|ParentNode} root
* @return {Promise<{tagGroups: {}, tagIncidences: Record<string, number>}>}
* @private
*/
async _getMediaTagGroupsAndIncidencesAsync(root)
{
return this._getMediaTagGroupsAndIncidencesFromRoot(root)
}
/**
* @return {{token: string, label: string}[]}
* @private
*/
_getDownloadPatternTokenChips()
{
return DOWNLOAD_PATTERN_SECTIONS.flatMap((section) => section.chips)
}
/**
* True when {@link pattern} references a chip via label, {label}, or {token}.
* Delegates to Download Manager (single source of truth).
* @param {string} pattern
* @param {{token?: string, label?: string}} chip
* @return {boolean}
* @private
*/
_patternIncludesDownloadChip(pattern, chip)
{
return this.getDownloadManager()?._patternIncludesChip(pattern, chip) ?? false
}
/**
* Tag-type tokens present as chips in the active filename/subfolder patterns.
* @return {string[]}
* @private
*/
_getPatternPinnedTagTypes()
{
let patterns = [
this._getConfig(DOWNLOAD_FILENAME_PATTERN) ?? '',
this._getConfig(DOWNLOAD_SUBFOLDER_PATTERN) ?? '',
]
let pinned = []
let seen = new Set()
for (let chip of this._getDownloadPatternTokenChips()) {
if (!TAG_TYPES.includes(chip.token) || seen.has(chip.token)) {
continue
}
for (let pattern of patterns) {
if (this._patternIncludesDownloadChip(pattern, chip)) {
seen.add(chip.token)
pinned.push(chip.token)
break
}
}
}
return pinned
}
/**
* Push pattern pins into live `tagDiscovery.tagTypes` so GreasyFork DM (array wins)
* and local DM (pins ∩ allowlist) both scan only pinned types.
* @private
*/
_syncDiscoveryTagTypesFromPatterns()
{
let pinned = this._getPatternPinnedTagTypes()
if (this._tagDiscoveryConfig) {
this._tagDiscoveryConfig.tagTypes = pinned
}
let discovery = this.getDownloadManager()?._config?.tagDiscovery
if (discovery) {
discovery.tagTypes = pinned
}
}
/**
* @param {string} tag
* @return {string}
* @private
*/
_normalizeTagAtom(tag)
{
return SEARCH_ADAPTER.normalizeAtom(tag)
}
/**
* Normalizes one line in a blacklist/explored ruleset field.
*
* @param {string} line
* @return {string}
* @private
*/
_normalizeBlacklistRuleLine(line)
{
let comment = ''
let commentIdx = line.indexOf(' // ')
if (commentIdx >= 0) {
comment = line.slice(commentIdx)
line = line.slice(0, commentIdx)
}
return line.split(/\s*&\s*/).
map((segment) => segment.split(/\s*\|\s*/).
map((alternative) => this._normalizeSimpleTagLine(alternative)).
join(' | ')).
join(' & ') + comment
}
/**
* @param {string} line
* @return {string}
* @private
*/
_normalizeSimpleTagLine(line)
{
return this._normalizeTagAtom(line)
}
/**
* @param {string} line
* @param {string} normalizedName
* @return {boolean}
* @private
*/
_tagBlacklistLineMatchesTag(line, normalizedName)
{
let rulePart = line
let commentIdx = line.indexOf(' // ')
if (commentIdx >= 0) {
rulePart = line.slice(0, commentIdx)
}
rulePart = rulePart.trim()
if (!rulePart || rulePart.includes('&') || rulePart.includes('|')) {
return false
}
return this._normalizeBlacklistRuleLine(rulePart) === normalizedName
}
/**
* @private
*/
_setupRulesetFieldMatchers()
{
let tagBlacklistOnMatchRule = (storedRule, target) => this._tagBlacklistLineMatchesTag(
storedRule, this._normalizeSimpleTagLine(target))
let blacklistField = this._configurationManager.getField(FILTER_TAG_BLACKLIST)
if (blacklistField) {
blacklistField.onMatchRule = tagBlacklistOnMatchRule
}
let exploredField = this._configurationManager.getField(FILTER_EXPLORED_TAGS)
if (exploredField) {
exploredField.onMatchRule = tagBlacklistOnMatchRule
}
let substitutionField = this._configurationManager.getField(DOWNLOAD_TAG_SUBSTITUTIONS)
if (substitutionField) {
substitutionField.onMatchRule = (storedRule, target) =>
this._normalizeTagAtom(storedRule.subject) === this._normalizeTagAtom(target)
}
let ignoreField = this._configurationManager.getField(DOWNLOAD_TAG_IGNORE)
if (ignoreField) {
ignoreField.onMatchRule = (storedRule, target) =>
this._normalizeSimpleTagLine(storedRule) === this._normalizeSimpleTagLine(target)
}
}
/**
* @param {string} line
* @return {string}
* @private
*/
_normalizeDefaultTagLine(line)
{
return SEARCH_ADAPTER.engine.tokenize(line).map((token) => this._normalizeTagAtom(token)).join(' ')
}
/**
* @return {{subject: string, replacement: string}[]}
* @private
*/
_getDownloadTagSubstitutions()
{
let field = this._configurationManager.getField(DOWNLOAD_TAG_SUBSTITUTIONS)
if (!field) {
return []
}
return field.optimized ?? field.value ?? []
}
/**
* @param {string[]|{subject: string, replacement: string}[]} rules
* @return {{subject: string, replacement: string}[]}
* @private
*/
_coerceDownloadTagSubstitutionRules(rules)
{
if (!Array.isArray(rules) || !rules.length) {
return []
}
if (typeof rules[0] === 'object' && rules[0]?.subject && rules[0]?.replacement) {
return rules
}
let manager = this.getDownloadManager()
return manager ? manager.parseDownloadTagSubstitutionLines(rules, (tag) => this._normalizeTagAtom(tag)) : []
}
/**
* @param {{subject: string, replacement: string}[]} rules
* @return {Map<string, string>}
* @private
*/
_buildDownloadTagSubstitutionMap(rules)
{
return this.getDownloadManager()?.buildDownloadTagSubstitutionMap(rules) ?? new Map()
}
/**
* @return {{chips: {token: string, label: string}[], tagTypes: string[], unknownTypes: Set<string>, unknownDefault: string, ignore: Set<string>, substitutions: Map<string, string>, stripCharacterSeries: boolean, multiTagSeparator: string}}
* @private
*/
_getDownloadPatternResolver()
{
return this._configurationManager.applyTagRegistryDownloadResolver({
chips: this._getDownloadPatternTokenChips(),
tagTypes: TAG_TYPES,
unknownTypes: DOWNLOAD_TAG_TOKEN_UNKNOWN_TYPES,
unknownDefault: DOWNLOAD_TAG_TOKEN_UNKNOWN_DEFAULT,
ignore: new Set(this._getConfig(DOWNLOAD_TAG_IGNORE)),
substitutions: this._buildDownloadTagSubstitutionMap(
this._coerceDownloadTagSubstitutionRules(this._getDownloadTagSubstitutions())),
stripCharacterSeries: this._getConfig(DOWNLOAD_STRIP_CHARACTER_SERIES),
multiTagSeparator: DOWNLOAD_MULTI_TAG_SEPARATOR,
normalizeTag: (value) => this._normalizeTagAtom(value),
})
}
/**
* @param {string[]} lines
* @return {{subject: string, replacement: string}[]}
* @private
*/
_parseDownloadTagSubstitutionLines(lines)
{
let manager = this.getDownloadManager()
if (!manager) {
return []
}
return manager.parseDownloadTagSubstitutionLines(lines, (tag) => this._normalizeTagAtom(tag))
}
/**
* @param {string} name
* @return {boolean}
* @private
*/
_isTagSubstitutionSubject(name)
{
return this._configurationManager.hasTagSubstitutionSubject(this._normalizeTagAtom(name))
}
/**
* @private
*/
_handleDownloadMedia()
{
if (this._isVideoMedia()) {
this._resolveVideoSourceForDownload((source) => {
void this.getDownloadManager()?.downloadImmediate({
mediaElement: source,
removeMediaOnSuccess: this._getConfig(MEDIA_REMOVE_ON_SUCCESS),
})
})
return
}
let media = this._getMediaElement()
if (media === null) {
return
}
let removeMediaOnSuccess = this._getConfig(MEDIA_REMOVE_ON_SUCCESS) && this._isPictureMedia()
void this.getDownloadManager()?.downloadImmediate({mediaElement: media, removeMediaOnSuccess})
}
/**
* Fires `callback` once `#gelcomVideoPlayer source` has a non-empty `src`.
*
* @param {function(HTMLSourceElement): void} callback
* @private
*/
_resolveVideoSourceForDownload(callback)
{
this._waitForDomElement(
SELECTOR_MEDIA_VIDEO_SOURCE,
(element) => element instanceof HTMLSourceElement && !!element.src,
(source) => callback(source),
)
}
/**
* @param {{name?: string|null, restorePictureOnFailure?: boolean}} download
* @protected
*/
_handleDuplicateDownloadSkipped(download)
{
// Always tear down capture/watcher on terminal paths (flag may be absent on races).
if (download.restorePictureOnFailure || _mediaResurrectionObserver || _capturedMediaUrl) {
_restoreMediaPicture()
}
}
/**
* @param {{name?: string|null, restorePictureOnFailure?: boolean}} download
* @protected
*/
_handleDownloadSucceeded(download)
{
if (download.restorePictureOnFailure || _mediaResurrectionObserver || _capturedMediaUrl) {
_releaseSuppressedMediaCapture()
}
}
/**
* @param {{name?: string|null, restorePictureOnFailure?: boolean}} download
* @param {*} error
* @protected
*/
_handleDownloadFailed(download, error)
{
if (download.restorePictureOnFailure || _mediaResurrectionObserver || _capturedMediaUrl) {
_restoreMediaPicture()
}
super._handleDownloadFailed(download, error)
}
/**
* @param {Document} doc
* @param {object} ctx
* @return {Promise<{mediaUrl: string, tagGroups: {}, tagIncidences: Record<string, number>, metadata: {}, downloadId: string|null, data: {}}>}
* @private
*/
async _resolvePostFromDocument(doc, ctx)
{
let mediaUrl = _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_/, '')
}
doc.querySelectorAll(SELECTOR_STATS + ' li').forEach((element) => {
let text = this._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} = await this._getMediaTagGroupsAndIncidencesAsync(doc)
return {
mediaUrl,
tagGroups,
tagIncidences,
metadata: {sourceUrl: ctx.sourceUrl},
downloadId: data.id || null,
data,
}
}
/**
* Tag discovery treats a tag as known only when {@link TagEntry.isDiscovered} is true.
* Attribute actions may set type for colors without marking discovered.
*
* @param {string} tagName
* @return {Promise<boolean>}
* @private
*/
async _isTagKnownForDiscovery(tagName)
{
let normalized = this._normalizeTagAtom(tagName)
if (!normalized) {
return false
}
let tagRuntime = this._configurationManager.getTagRuntime()
let cached = tagRuntime?.resolveCachedByName?.(normalized)
if (cached) {
return cached.isDiscovered === true
}
if (!this._configurationManager.canPersist()) {
return false
}
let tag = await this._configurationManager.getRepos().tags.getByName(normalized)
return !!(tag && tag.isDiscovered === true)
}
/**
* @private
*/
_setupDownloadManager()
{
this.configureDownloadManager({
enabled: true,
linkQueues: false,
resolutionInitiationGapMs: RESOLUTION_INITIATION_GAP_MS,
downloadInitiationGapMs: DOWNLOAD_INITIATION_GAP_MS,
downloadPaths: {
folderConfigKey: DOWNLOAD_FOLDER,
filenamePatternConfigKey: DOWNLOAD_FILENAME_PATTERN,
subfolderPatternConfigKey: DOWNLOAD_SUBFOLDER_PATTERN,
defaultFolder: DEFAULT_DOWNLOAD_FOLDER,
getPatternResolver: () => this._getDownloadPatternResolver(),
extractMediaData: (mediaElement) => this._getMediaData(mediaElement),
extractTagGroups: () => this._getMediaTagGroupsAsync(),
extractTagIncidences: () => this._getMediaTagIncidencesAsync(),
appendExtension: true,
nameFallback: (data) => data.md5 || data.id || 'media',
},
pages: {
home: {
roles: ['dashboard'],
},
search: {
roles: ['selection', 'tagDiscoveryToggle'],
defaultDownloadType: 'post',
itemSelector: SELECTOR_ITEM,
resolveItem: (item) => {
let postId = this._getItemPostId(item)
if (!postId) {
return null
}
let href = item.querySelector(SELECTOR_ITEM_LINK)?.getAttribute('href') ?? ''
return {
itemId: String(postId),
sourceUrl: new URL(href, location.origin).href,
downloadType: 'post',
}
},
},
media: {
roles: ['enqueueMedia', 'immediateDownload', 'tagDiscoveryToggle'],
defaultDownloadType: 'post',
},
},
downloadTypes: {
post: {
resolveFromSearch: (ctx) => ({
nextUrl: ctx.sourceUrl,
downloadType: 'post',
}),
resolveFromMedia: (doc, ctx) => this._resolvePostFromDocument(doc, ctx),
},
},
rateLimitHandlers: {
resolution: {
timedReload: {
detect: (doc) => this._is429RateLimitDoc(doc),
delayMs: () => RATE_LIMIT_REFRESH_DELAY_MS,
},
humanInteraction: {
detect: (doc) => this._isCloudflareChallengeDoc(doc),
openUrl: (ctx) => ctx.resolvingUrl ?? ctx.sourceUrl ?? location.href,
confirmTitle: 'Cloudflare challenge',
challengeConfirmMessage:
'Complete the Cloudflare check on this page, then resume the download queue.',
},
},
download: {
humanInteraction: {
detect: (docOrSignal) => {
if (docOrSignal?.status === 429) {
return true
}
return this._isCloudflareChallengeDoc(docOrSignal?.doc ?? docOrSignal)
},
openUrl: (ctx) => ctx.resolvingUrl ?? ctx.sourceUrl ?? location.href,
confirmTitle: 'Rate limit — verification required',
challengeConfirmMessage:
'Complete verification on this page, then resume the download queue.',
},
},
},
tagDiscovery: this._tagDiscoveryConfig = {
discoverAt: ['mediaPost'],
// Synced from filename/subfolder pins — array form so GreasyFork DM cannot scan all types.
tagTypes: [],
isTagKnown: async (tagName) => this._isTagKnownForDiscovery(tagName),
actions: this._getTagAttributeActionsConfig(),
panel: {
tagTypes: TAG_DISCOVERY_TYPES,
groupOrder: [...TAG_TYPES, 'unknown'],
getGroupKey: (tag) => tag.type ?? 'unknown',
},
},
onBeforeDownload: ({mediaElement, removeMediaOnSuccess}) => {
let downloadUrl = _getOriginalMediaUrlFromDocument(document)
if (downloadUrl && _isPreferredMediaUrl(downloadUrl, _capturedMediaUrl)) {
_capturedMediaUrl = downloadUrl
}
let suppressedForDownload = false
if (removeMediaOnSuccess && mediaElement instanceof HTMLImageElement) {
_suppressMediaForDownload(mediaElement)
suppressedForDownload = true
let captured = _getCapturedMediaElement()
if (captured) {
mediaElement = captured
}
} else if (removeMediaOnSuccess && mediaElement instanceof HTMLSourceElement) {
_suppressVideoForDownload(mediaElement)
suppressedForDownload = true
}
return {
downloadUrl,
mediaElement,
restoreOnFailure: suppressedForDownload,
}
},
getQueueItemId: (context) => {
if (context.itemId) {
return String(context.itemId)
}
let sourceUrl = context.sourceUrl ?? location.href
let match = String(sourceUrl).match(/[?&]id=(\d+)/)
return match ? match[1] : (QUERY_PARAMS.get('id') ?? null)
},
})
this._syncDiscoveryTagTypesFromPatterns()
let dm = this.getDownloadManager()
if (dm) {
let toggleSelectionMode = dm.toggleSelectionMode.bind(dm)
dm.toggleSelectionMode = () => {
toggleSelectionMode()
this._syncSelectionItemDragDisabled()
}
}
this._onAfterInitialization.push(() => this._syncSelectionItemDragDisabled())
}
/**
* While download selection mode is on, block native drag on search thumbs
* so click-to-queue is not stolen by image/link dragging.
* @private
*/
_syncSelectionItemDragDisabled()
{
let dm = this.getDownloadManager()
let active = !!(dm?.isDownloadManagerEnabled?.() && dm._isSelectionModeActive?.())
document.body.classList.toggle('r34-dm-selection', active)
if (this._selectionDragStartHandler) {
document.removeEventListener('dragstart', this._selectionDragStartHandler, true)
this._selectionDragStartHandler = null
}
if (!active || !this.isPage('search')) {
return
}
this._selectionDragStartHandler = (event) => {
if (event.target instanceof Element && event.target.closest(SELECTOR_ITEM)) {
event.preventDefault()
}
}
document.addEventListener('dragstart', this._selectionDragStartHandler, true)
}
/**
* Navigates to the next search page when no compliant items remain.
*
* @private
*/
_handleSearchAutoNext()
{
if (this._getConfig(SEARCH_AUTO_NEXT) && document.querySelectorAll('.' + CLASS_COMPLIANT_ITEM).length === 0) {
document.querySelector(SELECTOR_PAGINATOR_NEXT)?.click()
}
}
/**
* Left / right arrow keys navigate to the previous / next search page.
*
* @param {KeyboardEvent} event
* @private
*/
_handleSearchKeyboardNavigation(event)
{
if (event.defaultPrevented || event.ctrlKey || event.altKey || event.metaKey) {
return
}
let target = event.target
if (target instanceof HTMLElement &&
(target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName))) {
return
}
let selector = event.key === 'ArrowLeft' ? SELECTOR_PAGINATOR_BACK
: event.key === 'ArrowRight' ? SELECTOR_PAGINATOR_NEXT
: null
if (!selector) {
return
}
let link = document.querySelector(selector)
if (link) {
event.preventDefault()
link.click()
}
}
// -------------------------------------------------------------------------
// Bookmarks
// -------------------------------------------------------------------------
/**
* @return {*|null}
* @private
*/
_getBookmarksField()
{
return this._configurationManager.getField(BOOKMARKS)
}
/**
* @return {boolean}
* @private
*/
_bookmarksUseRepository()
{
return this._configurationManager.isStorageReady()
}
/**
* @return {Promise<Array>}
* @private
*/
async _listBookmarks()
{
if (this._bookmarksUseRepository()) {
return this._configurationManager.getRepos().bookmarks.listAll()
}
let field = this._getBookmarksField()
return field?.value ?? []
}
/**
* @param {Array} bookmarks
* @return {Promise<void>}
* @private
*/
async _renderBookmarks(bookmarks)
{
let field = this._getBookmarksField()
if (!field?.widget) {
return
}
field.widget.render(bookmarks)
field.widget.checkPageMatch?.()
}
/**
* @private
*/
async _handleSortBookmarks()
{
let field = this._getBookmarksField()
if (!field) {
return
}
if (this._bookmarksUseRepository()) {
let repos = this._configurationManager.getRepos().bookmarks
let bookmarks = await repos.listAll()
bookmarks.sort((left, right) => left.label.localeCompare(right.label, undefined, {sensitivity: 'base'}))
for (let index = 0; index < bookmarks.length; index++) {
bookmarks[index].sortOrder = index
await repos.update(bookmarks[index])
}
return
}
field.value.sort((left, right) => left.label.localeCompare(right.label, undefined, {sensitivity: 'base'}))
await field.save()
}
/**
* @param {number|string} entryId
* @private
*/
async _handleRemoveBookmark(entryId)
{
let field = this._getBookmarksField()
if (!field) {
return
}
if (this._bookmarksUseRepository()) {
let removeId = Number.isFinite(Number(entryId)) ? Number(entryId) : entryId
await this._configurationManager.getRepos().bookmarks.remove(removeId)
} else {
let before = field.value.length
field.value = field.value.filter((bookmark) => bookmark.id !== entryId &&
bookmark.entryId !== entryId)
if (field.value.length === before) {
return
}
await field.save()
}
await this._refreshBookmarkPanel()
this._refreshDockButtonStates()
}
/**
* @private
*/
async _refreshBookmarkPanel()
{
let field = this._getBookmarksField()
if (!field) {
return
}
await field.reload()
await this._renderBookmarks(await this._listBookmarks())
}
/**
* @param {string} tags
* @return {string}
* @private
*/
_formatBookmarkLabel(tags)
{
return tags.replaceAll('_', ' ')
}
/**
* Returns user-entered search tags only, excluding script default tags and
* resolution metatags merged into live searches.
*
* @return {string}
* @private
*/
_getCurrentBookmarkTags()
{
let tags = ''
let input = document.querySelector(SELECTOR_SEARCH_INPUT)
if (input?.value.trim()) {
tags = input.value
} else {
tags = QUERY_PARAMS.get('tags') ?? ''
}
if (!tags.trim()) {
return ''
}
return SEARCH_ADAPTER.engine.subtract(SEARCH_ADAPTER.engine.tokenize(tags), this._getDefaultTokens()).join(' ')
}
/**
* @param {string} tags
* @return {string}
* @private
*/
_buildBookmarkSearchUrl(tags)
{
return this._buildSearchListUrl(tags)
}
/**
* Canonical bookmark-list URL used for page-match comparisons.
*
* @param {string} url
* @return {string}
* @private
*/
_normalizeBookmarkUrl(url)
{
try {
let parsed = new URL(url, location.origin)
if (parsed.searchParams.get('page') !== 'post' || parsed.searchParams.get('s') !== 'list') {
return ''
}
let tags = parsed.searchParams.get('tags') ?? ''
if (!tags.trim()) {
return ''
}
let userTags = SEARCH_ADAPTER.engine.subtract(SEARCH_ADAPTER.engine.tokenize(tags), this._getDefaultTokens()).join(' ')
if (!userTags.trim()) {
return ''
}
return this._buildBookmarkSearchUrl(userTags)
} catch (error) {
return ''
}
}
/**
* Site incidence options for framework-owned tag attribute actions.
* Persist toggles notify `tags` with detail — chrome refreshes from onConfigurationChange.
* Bookmark star still needs a local after hook (bookmarks notify, not tags).
* @return {object}
* @private
*/
_getTagAttributeActionsConfig()
{
return {
className: 'r34-tag-action-btn',
iconClass: 'r34-tag-action-icon',
ignoreClassName: 'r34-tag-action-btn r34-tag-action-btn--ignore',
normalize: (value) => this._normalizeTagAtom(value),
bookmark: {
fieldKey: BOOKMARKS,
buildUrl: (name) => this._buildTagBookmarkUrl(name),
formatLabel: (tags) => this._formatBookmarkLabel(tags),
normalizeUrl: (url) => this._normalizeBookmarkUrl(url),
iconClass: BOOKMARK_STAR_ICON_CLASS,
onAfterToggle: () => this._refreshTagSidebarActions(),
},
ignore: {fieldKey: DOWNLOAD_TAG_IGNORE},
substitute: {fieldKey: DOWNLOAD_TAG_SUBSTITUTIONS},
blacklist: {
fieldKey: FILTER_TAG_BLACKLIST,
ensureOptionKey: OPTION_ENABLE_TAG_BLACKLIST,
},
explore: {
fieldKey: FILTER_EXPLORED_TAGS,
ensureOptionKey: OPTION_ENABLE_EXPLORED_TAGS_TRACKER,
},
getRowState: (tag) => ({
muted: this._isTagInIgnoreList(tag.name) || this._isTagSubstitutionSubject(tag.name),
hidden: this.hasTagSoleAttributeActive(tag.name, {
fieldKey: FILTER_TAG_BLACKLIST,
normalize: (value) => this._normalizeTagAtom(value),
}) || this.hasTagSoleAttributeActive(tag.name, {
fieldKey: FILTER_EXPLORED_TAGS,
normalize: (value) => this._normalizeTagAtom(value),
}),
}),
extraMutedClasses: ['r34-tag-muted'],
extraHiddenClasses: ['r34-tag-hidden'],
}
}
/**
* @param {boolean} filled
* @return {SVGSVGElement}
* @private
*/
_createBookmarkStarIcon(filled)
{
return BrazenViewLayer.createTagBookmarkStarIcon(filled, BOOKMARK_STAR_ICON_CLASS)
}
// -------------------------------------------------------------------------
// Settings panel patches
// -------------------------------------------------------------------------
/**
* @param {string} configKey
* @return {HTMLElement}
* @private
*/
_createPatternGroup(configKey)
{
let element = this._configurationManager.createElement(configKey)
element.classList.add('bv-pattern-group')
return element
}
/**
* @param {Array} stored
* @return {{entryId?: number, id?: string, label: string, tags: string, url: string}[]}
* @private
*/
_normalizeBookmarksFromStorage(stored)
{
if (!Array.isArray(stored)) {
return []
}
return stored.
map((bookmark) => {
if (!bookmark || typeof bookmark !== 'object') {
return null
}
let tags = typeof bookmark.tags === 'string' ? bookmark.tags.trim() : ''
if (!tags.length) {
return null
}
let entryId = bookmark.entryId ?? null
let id = bookmark.id !== null && bookmark.id !== undefined ? String(bookmark.id).trim() : ''
let url = typeof bookmark.url === 'string' ? bookmark.url.trim() : ''
if (!url.length) {
url = this._buildBookmarkSearchUrl(tags)
}
return {
entryId,
id: id || undefined,
label: bookmark.label || this._formatBookmarkLabel(tags),
tags,
url,
}
}).
filter(Boolean)
}
/**
* @return {{ok: boolean, reason?: string, filename?: boolean, subfolder?: boolean, builder?: boolean}}
* @private
*/
_patchPatternTokenBuilder()
{
let filenameEl = this._configurationManager.getField(DOWNLOAD_FILENAME_PATTERN)?.element
let subfolderEl = this._configurationManager.getField(DOWNLOAD_SUBFOLDER_PATTERN)?.element
let builder = document.querySelector('#bv-ui .bv-pattern-builder-shared')
return this._uiGen.patchPatternTokenBuilder(filenameEl, subfolderEl, builder, 'r34pattern')
}
/**
* @return {{ok: boolean, reason?: string}}
* @private
*/
_patchPanelResizer()
{
let resizer = document.getElementById('bv-resizer')
let panel = document.getElementById('bv-ui')
if (!resizer || !panel) {
return {ok: false, reason: 'panel-missing'}
}
if (this._panelResizerMousedownHandler) {
resizer.removeEventListener('mousedown', this._panelResizerMousedownHandler)
}
this._panelResizerMousedownHandler = (event) => {
event.preventDefault()
let resizeHorizontal = (moveEvent) => {
let rect = panel.getBoundingClientRect()
let resizeFromLeft = panel.classList.contains('bv-dock-panel-right')
panel.style.width = resizeFromLeft ?
`${Math.max(150, moveEvent.clientX - rect.left)}px` :
`${Math.max(150, rect.right - moveEvent.clientX)}px`
}
let stopResize = () => {
removeEventListener('mousemove', resizeHorizontal)
removeEventListener('mouseup', stopResize)
}
addEventListener('mousemove', resizeHorizontal)
addEventListener('mouseup', stopResize)
}
resizer.addEventListener('mousedown', this._panelResizerMousedownHandler)
return {ok: true}
}
/**
* @param {string} tagName
* @return {string}
* @private
*/
_buildTagBookmarkUrl(tagName)
{
return this._buildSearchListUrl(this._normalizeTagAtom(tagName))
}
/**
* @param {HTMLElement} row
* @return {{name: string, type: string|null}}
* @private
*/
_extractSidebarTag(row)
{
let name = this._normalizeTagAtom(this._sidebarRowTagName(row) ?? '')
return {
name: name ?? '',
type: this._sidebarRowType(row),
}
}
/**
* @param {string} name
* @return {boolean}
* @private
*/
_isTagInIgnoreList(name)
{
return this._configurationManager.hasTagSoleAttribute(DOWNLOAD_TAG_IGNORE, this._normalizeSimpleTagLine(name))
}
/**
* @param {HTMLElement} row
* @param {{name: string, type: string|null}} tag
* @param {HTMLElement} actionsElement
* @private
*/
_renderTagSidebarActions(row, tag, actionsElement)
{
let config = this._getTagAttributeActionsConfig()
actionsElement.replaceChildren()
this.appendTagAttributeActions(actionsElement, tag, config)
let state = config.getRowState?.(tag) ?? {}
row.classList.toggle('bv-tag-muted', !!state.muted)
row.classList.toggle('bv-tag-hidden', !!state.hidden)
row.classList.toggle('r34-tag-muted', !!state.muted)
row.classList.toggle('r34-tag-hidden', !!state.hidden)
}
/**
* @param {string} tagName
* @return {boolean}
* @private
*/
_sidebarContainsTag(tagName)
{
let target = this._normalizeTagAtom(tagName)
if (!target) {
return false
}
for (let row of document.querySelectorAll(`${SELECTOR_TAG_SIDEBAR} li[class*="tag-type-"]`)) {
let tag = this._extractSidebarTag(row)
if (tag?.name && this._normalizeTagAtom(tag.name) === target) {
return true
}
}
return false
}
/**
* Gated chrome refresh — only surfaces that reference the changed tags.
* @param {{tags?: string[], fieldKeys?: string[]}|null} [detail]
* @private
*/
_refreshTagActionSurfaces(detail = null)
{
this.refreshTagActionSurfaces(detail, {
sidebarContainsTag: (tag) => this._sidebarContainsTag(tag),
refreshSidebarRows: (tags) => this._refreshTagSidebarActions(tags),
refreshBookmarks: () => this._getBookmarksField()?.updateUserInterface?.(),
refreshDiscovery: (tags) => this.getDownloadManager()?.refreshTagDiscoveryPanel(tags),
refreshDock: () => this._refreshDockButtonStates(),
})
}
/**
* @param {string[]|null} [onlyTagNames]
* @private
*/
async _setupTagSidebarActions(onlyTagNames = null)
{
let generation = ++this._tagSidebarActionsGeneration
let scopedNames = Array.isArray(onlyTagNames) && onlyTagNames.length ?
onlyTagNames.map((name) => this._normalizeTagAtom(name)).filter(Boolean) : null
let paint = () => {
if (generation !== this._tagSidebarActionsGeneration) {
return
}
let bookmarksField = this._getBookmarksField()
this._uiGen.enhanceTagSidebarCounts({
sidebarSelector: SELECTOR_TAG_SIDEBAR,
rowSelector: 'li[class*="tag-type-"]',
extractTag: (row) => this._extractSidebarTag(row),
onlyTagNames: scopedNames,
renderActions: (row, tag, actionsElement) => {
this._renderTagSidebarActions(row, tag, actionsElement)
},
isBookmarked: (url) => bookmarksField?.widget?.isCurrentPageBookmarked(url) ?? false,
})
}
// Paint immediately so hover actions are clickable; icons heal after ensureNames.
paint()
let tagRuntime = this._configurationManager.getTagRuntime()
if (!tagRuntime) {
return
}
let names = scopedNames ? scopedNames.slice() : []
if (!scopedNames) {
for (let row of document.querySelectorAll(`${SELECTOR_TAG_SIDEBAR} li[class*="tag-type-"]`)) {
let tag = this._extractSidebarTag(row)
if (tag?.name) {
names.push(tag.name)
}
}
}
let needsEnsure = names.filter((name) => !this._configurationManager.getCachedTagEntry(name))
if (!needsEnsure.length) {
return
}
await tagRuntime.ensureNames(needsEnsure)
paint()
}
/**
* @param {string[]|null} [onlyTagNames]
* @private
*/
_refreshTagSidebarActions(onlyTagNames = null)
{
void this._setupTagSidebarActions(onlyTagNames)
}
/**
* @param {string} ruleLabel
* @param {string} line
* @return {boolean}
* @private
*/
_blacklistRuleLabelMatchesLine(ruleLabel, line)
{
let normalizedLabel = this._normalizeBlacklistRuleLine(ruleLabel)
let rulePart = line
let commentIdx = line.indexOf(' // ')
if (commentIdx >= 0) {
rulePart = line.slice(0, commentIdx)
}
let normalizedRulePart = this._normalizeBlacklistRuleLine(rulePart)
if (normalizedRulePart === normalizedLabel) {
return true
}
if (normalizedRulePart.includes(' | ')) {
return normalizedRulePart.split(/\s*\|\s*/).includes(normalizedLabel)
}
return false
}
/**
* @param {string} fieldKey
* @param {string} ruleLabel
* @return {number}
* @private
*/
_findTagRuleLineIndexForRuleLabel(fieldKey, ruleLabel)
{
let field = this._configurationManager.getField(fieldKey)
if (!field?.value?.length) {
return -1
}
let index = field.value.findIndex((line) => this._blacklistRuleLabelMatchesLine(ruleLabel, line))
if (index >= 0) {
return index
}
let normalizedLabel = this._normalizeBlacklistRuleLine(ruleLabel)
if (!field.onOptimize) {
return -1
}
return field.value.findIndex((line) => {
let optimized = Utilities.callEventHandler(field.onOptimize, [[line]])
return optimized?.some((rule) => this._normalizeBlacklistRuleLine(rule.join(' & ')) === normalizedLabel)
})
}
/**
* @param {string} filterKey
* @return {boolean}
* @private
*/
_isRemovableTagComplianceFilter(filterKey)
{
return filterKey === FILTER_TAG_BLACKLIST || filterKey === FILTER_EXPLORED_TAGS
}
/**
* @param {string} filterKey
* @param {string} ruleLabel
* @return {boolean}
* @protected
*/
_canRemoveComplianceRule(filterKey, ruleLabel)
{
if (!this._isRemovableTagComplianceFilter(filterKey)) {
return false
}
return this._findTagRuleLineIndexForRuleLabel(filterKey, ruleLabel) >= 0
}
/**
* @param {string} filterKey
* @param {string} ruleLabel
* @return {Promise<void>}
* @protected
*/
async _removeComplianceRule(filterKey, ruleLabel)
{
if (!this._isRemovableTagComplianceFilter(filterKey)) {
return
}
let field = this._configurationManager.getField(filterKey)
if (!field) {
return
}
let lines = [...(field.value ?? [])]
let index = this._findTagRuleLineIndexForRuleLabel(filterKey, ruleLabel)
if (index < 0) {
return
}
lines.splice(index, 1)
// Registry fields ignore setRules+save unless the textarea is dirty — persist via writeTagRuleLines.
await this._configurationManager.writeTagRuleLines(filterKey, lines)
}
/**
* Color single-token tag blacklist / explore rules only. Non-tag filters stay default text.
* @param {string} filterKey
* @param {string} ruleLabel
* @return {string|null}
* @protected
*/
_getComplianceRuleColor(filterKey, ruleLabel)
{
if (!this._isRemovableTagComplianceFilter(filterKey)) {
return null
}
let normalizedLabel = this._normalizeBlacklistRuleLine(ruleLabel)
if (!normalizedLabel || normalizedLabel.includes(' & ')) {
return null
}
let typeName = this._resolveComplianceRuleTagType(normalizedLabel)
return TAG_DISCOVERY_TYPES[typeName]?.color ?? null
}
/**
* Resolve a single-token rule label to a canonical type name for Active Hide Rules colors.
* @param {string} normalizedLabel
* @return {string|null}
* @private
*/
_resolveComplianceRuleTagType(normalizedLabel)
{
for (let row of document.querySelectorAll(SELECTOR_TAG_SIDEBAR_ROW)) {
let name = this._sidebarRowTagName(row)
if (!name) {
continue
}
if (this._normalizeTagAtom(name) !== normalizedLabel) {
continue
}
return this._sidebarRowType(row)
}
let cached = this._configurationManager.getCachedTagEntry(normalizedLabel)
let typeEntryId = cached?.typeEntryId ?? cached?.meta?.lastSeenTypeEntryId ?? null
if (typeEntryId == null) {
return null
}
return TYPE_ENTRY_ID_TO_TYPE[typeEntryId] ?? null
}
/**
* @private
*/
_onApplyNewSettings()
{
this._configurationManager.update()
if (this.isPage('search')) {
this._handleDefaultInjection()
if (!this._redirecting) {
this._refreshSearchInputDisplay()
}
}
this._validateCompliance()
this._refreshTagSidebarActions()
this._refreshBookmarkPanel()
this._refreshDockButtonStates()
}
// -------------------------------------------------------------------------
// Setup
// -------------------------------------------------------------------------
/**
* @private
*/
_setupFeatures()
{
this._configurationManager.addBookmarksField(BOOKMARKS).
setTitle('Bookmarks').
setHelpText('Save the current search tags as a bookmark.').
setNormalizeBookmarks((stored) => this._normalizeBookmarksFromStorage(stored)).
setShowAddButton(false).
setPageMatch({
getCurrentUrl: () => location.href,
normalizeUrl: (url) => this._normalizeBookmarkUrl(url),
onMatchChange: () => this._refreshDockButtonStates(),
watchSelectors: [SELECTOR_SEARCH_INPUT],
isActive: () => this.isPage('search'),
}).
setOnSort(() => this._handleSortBookmarks()).
setOnRemove((id) => this._handleRemoveBookmark(id)).
setAttributeActionsConfig({
host: this,
tokenize: (tags) => SEARCH_ADAPTER.engine.tokenize(tags),
normalize: (value) => this._normalizeTagAtom(value),
blacklist: {fieldKey: FILTER_TAG_BLACKLIST},
explore: {fieldKey: FILTER_EXPLORED_TAGS},
iconClass: 'r34-tag-action-icon',
// Persist toggles notify `tags` with detail — gated refresh via onConfigurationChange.
}).
reload()
this._configurationManager.addFlagField(OPTION_ENABLE_DEFAULT_TAGS).
setTitle('Enable Default Tags').
setHelpText('Add the default tags below to every search.').
applyDockTemplate('defaultTags')
this._configurationManager.addRulesetField(DEFAULTS_TAGS).
setTitle('Default Tags').
setRows(3).
setHelpText('Tags added to every search. One tag or rule per line; the full search grammar is allowed (e.g. <code>sort:score:desc</code>, <code>-futanari</code>, <code>( catgirl ~ foxgirl )</code>).').
setTranslateFromUI((lines) => lines.map((line) => this._normalizeDefaultTagLine(line)))
this._configurationManager.addFlagField(OPTION_ENABLE_RESOLUTION).
setTitle('Enable Resolution Filter').
setHelpText('Restrict searches to the width / height range below.').
applyDockTemplate('resolutionFilter')
this._configurationManager.addRangeField(DEFAULTS_WIDTH, 0, 100000).
setTitle('Width').
setHelpText('Only show posts within this width (pixels). Leave a bound at 0 to ignore it.')
this._configurationManager.addRangeField(DEFAULTS_HEIGHT, 0, 100000).
setTitle('Height').
setHelpText('Only show posts within this height (pixels). Leave a bound at 0 to ignore it.')
this._configurationManager.addFlagField(MEDIA_AUTO_DOWNLOAD).
setTitle('Auto Download Media').
setHelpText('Automatically download the media when opening a post page.').
applyDockTemplate('autoDownload')
this._configurationManager.addFlagField(MEDIA_REMOVE_ON_SUCCESS).
setTitle('Remove Media On Download Success').
setHelpText('Best effort to avoid a second in-page fetch of the media — only the script save runs when suppression wins. With auto-download, the picture or video player is removed even when the post was already saved; it stays on the page only if a new download attempt fails (pictures can be restored on failure).').
applyDockTemplate('removeMediaOnDownload')
this._configurationManager.addTextField(DOWNLOAD_FOLDER).
setTitle('Download Folder').
setHelpText('Root folder all downloads are saved under. It can be renamed but not removed - clearing it restores the default.').
setDefault(DEFAULT_DOWNLOAD_FOLDER)
this._configurationManager.addTextField(DOWNLOAD_FILENAME_PATTERN).
setTitle('Filename Pattern').
setHelpText('Tap the chips below to build the saved file name. The file extension is added automatically.').
setDefault('ID')
this._configurationManager.addTextField(DOWNLOAD_SUBFOLDER_PATTERN).
setTitle('Subfolder Pattern').
setHelpText('Optional — tap chips to add sub-folders inside your download folder. Use / between folder levels. Leave empty for none.')
this._configurationManager.addRulesetField(DOWNLOAD_TAG_SUBSTITUTIONS).
setTitle('Filename Tag Substitutions').
setRows(3).
setHelpText('Rename tags for filename and subfolder tokens. Use Subject → Alias above (underscores, not spaces). The list below is read-only.').
setTranslateFromUI((lines) => this._parseDownloadTagSubstitutionLines(lines)).
setFormatForUI((rules) => {
if (!Array.isArray(rules)) {
return []
}
return rules.map((rule) => typeof rule === 'string' ? rule : rule.subject + ' → ' + rule.replacement)
}).
setOptimize((rules) => {
let map = new Map()
for (let rule of Array.isArray(rules) ? rules : []) {
if (!rule?.subject || !rule?.replacement) {
continue
}
map.set(rule.subject, rule.replacement)
}
return [...map.entries()].
sort((left, right) => left[0].localeCompare(right[0], undefined, {sensitivity: 'base'})).
map(([subject, replacement]) => ({subject, replacement}))
}).
setSortRules(true).
setSubstitutionComposer({
normalize: (tag) => this._normalizeTagAtom(tag),
subjectPlaceholder: 'Subject',
aliasPlaceholder: 'Alias',
addHelpText: 'Add Subject → Alias substitution',
})
this._configurationManager.addRulesetField(DOWNLOAD_TAG_IGNORE).
setTitle('Filename Tag Ignore List').
setRows(3).
setHelpText('Tags to exclude from tag-type tokens in file names. One tag per line.').
setTranslateFromUI((lines) => lines.map((line) => this._normalizeSimpleTagLine(line))).
setSortRules(true)
this._configurationManager.addFlagField(DOWNLOAD_STRIP_CHARACTER_SERIES).
setTitle('Strip Series From Character Tags').
setHelpText('Drop the series suffix from disambiguated character tags (e.g. <code>sciel_(clair_obscur_expedition_33)</code> → <code>sciel</code>) in filename and subfolder tokens.')
this._configurationManager.addFlagField(SEARCH_AUTO_NEXT).
setTitle('Auto Next Page').
setHelpText('Automatically navigate to the next page when every result is filtered out.').
applyDockTemplate('autoNextPage')
this._addItemTagAttribute(ITEM_TAGS, false, false, (item) => this._extractItemTags(item))
this._itemAttributesResolver.addAttribute(ITEM_POST_ID, (item) => this._getItemPostId(item))
}
/**
* @private
*/
_setupFilters()
{
this._addItemTagBlacklistFilter(
ITEM_TAGS, false, 3,
'Hide posts with these tags. One tag name per line (underscores, not spaces).<br>' +
'Combine with <code>&</code> (and) / <code>|</code> (or); add <code>// note</code> for comments.')
let blacklistField = this._configurationManager.getField(FILTER_TAG_BLACKLIST)
blacklistField.onTranslateFromUI = (lines) => lines.map((line) => this._normalizeBlacklistRuleLine(line))
this._addItemTagBlacklistFilter(
ITEM_TAGS, false, 3,
'Hide posts with these explored tags while paging through large result sets. One tag name per line (underscores, not spaces).<br>' +
'Sidebar hide buttons add here when the tracker is enabled. Combine with <code>&</code> (and) / <code>|</code> (or); add <code>// note</code> for comments.',
FILTER_EXPLORED_TAGS, OPTION_ENABLE_EXPLORED_TAGS_TRACKER, 'exploredTags')
this._configurationManager.getField(OPTION_ENABLE_EXPLORED_TAGS_TRACKER)?.setTitle('Enable Explored Tags Tracker')
this._configurationManager.getField(FILTER_EXPLORED_TAGS)?.setTitle('Explored Tags Tracker')
let exploredField = this._configurationManager.getField(FILTER_EXPLORED_TAGS)
exploredField.onTranslateFromUI = (lines) => lines.map((line) => this._normalizeBlacklistRuleLine(line))
this._configurationManager.addFlagField(OPTION_HIDE_OLDER_POSTS).
setTitle('Hide Older Posts').
setHelpText('Hide posts with an id below the Last ID value.').
applyDockTemplate('hideOlderPosts')
this._configurationManager.addNumberField(LAST_ID, 0, Number.MAX_SAFE_INTEGER).
setTitle('Last ID').
setHelpText('Posts with an id below this are hidden. Use the capture button to set it from the current page.')
this._addItemComplexComplianceFilter(
OPTION_HIDE_OLDER_POSTS,
(enabled) => enabled && this._getConfig(LAST_ID) > 0,
(item) => {
let id = this._get(item, ITEM_POST_ID)
let lastId = this._getConfig(LAST_ID)
if (id === null || id >= lastId) {
return true
}
return {complies: false, rule: 'Post id < ' + lastId}
})
// Prefer live post id — cached attributes can miss it after DOM refresh.
this._addItemHideDownloadedMediaFilter(
(item) => this._getItemPostId(item) ?? this._get(item, ITEM_POST_ID),
OPTION_HIDE_DOWNLOADED_MEDIA_HELP,
)
this._setupRulesetFieldMatchers()
}
/**
* @private
*/
_setupDock()
{
this.registerCurrentSearchBookmarkDock(DOCK_BOOKMARK_SEARCH, {
fieldKey: BOOKMARKS,
getCurrentUrl: () => location.href,
getTags: () => this._getCurrentBookmarkTags(),
formatLabel: (tags) => this._formatBookmarkLabel(tags),
normalizeUrl: (url) => this._normalizeBookmarkUrl(url),
include: function() { return this.isPage('search') },
})
this._configurationManager.getField(MEDIA_AUTO_DOWNLOAD).
setDockSlideOut([MEDIA_REMOVE_ON_SUCCESS])
this._configurationManager.addActionField(DOCK_DOWNLOAD_MEDIA).
setTitle('Download Media').
setHelpText('Downloads the media on this post page.').
setAction(() => this._handleDownloadMedia()).
applyDockTemplate('singleDownload', {
include: function() {
return this.isPage('media') && !this._getConfig(MEDIA_AUTO_DOWNLOAD)
},
})
// Framework / registration templates own button bodies; app only sets slide-out children.
this._configurationManager.getField(OPTION_DISABLE_COMPLIANCE_VALIDATION).
setDockSlideOut([
OPTION_ENABLE_DEFAULT_TAGS,
OPTION_ENABLE_RESOLUTION,
OPTION_ENABLE_TAG_BLACKLIST,
])
this._configurationManager.addActionField(DOCK_SET_LATEST_ID).
setTitle('Set Latest ID').
setHelpText('Use the highest post id on this page as the Last ID.').
setAction(() => this._handleSetMarkerFromSearch()).
setDockButton({
icon: 'target',
tooltip: 'Capture highest post id as Last ID',
include: function() { return this.isPage('search') },
})
this._configurationManager.addActionField(DOCK_SET_POST_ID).
setTitle('Set Post ID').
setHelpText('Use this post id as the Last ID.').
setAction(() => this._handleSetMarkerFromMedia()).
setDockButton({
icon: 'target',
tooltip: 'Capture this post id as Last ID',
include: function() { return this.isPage('media') },
})
this._configurationManager.getField(OPTION_HIDE_OLDER_POSTS).
setDockSlideOut([DOCK_SET_LATEST_ID, DOCK_SET_POST_ID]).
setDockButton({
include: function() { return !this.isPage('home') },
})
// Homepage is a dedicated control shell — keep page-oriented ledger/filter dock
// controls off here. Framework does not auto-hide these on `dashboard` pages so
// other consumers can still surface them when home has search/media chrome.
this._configurationManager.getField(OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER)?.
setDockButton({
include: function() {
return this.isDownloadManagerEnabled() && !this.isPage('home')
},
})
this._configurationManager.getField(OPTION_HIDE_DOWNLOADED_MEDIA)?.
setDockButton({
include: function() {
return this.isDownloadManagerEnabled() && !this.isPage('home')
},
})
}
/**
* @param {HTMLElement} railBody
*/
composeDockRail(railBody)
{
this._configurationManager.setDockIncludeContext(this)
this._configurationManager.clearDockElements()
this._uiGen.mountDockRail(railBody, this._createDockRailItems())
}
/**
* @return {HTMLElement[]}
* @private
*/
_createDockRailItems()
{
let items = []
let pushField = (key) => {
if (!this._configurationManager.hasField(key)) {
return
}
if (!this._configurationManager.evaluateDockInclude(key)) {
return
}
let node = this._configurationManager.createDockElement(key)
if (node != null) {
items.push(node)
}
}
let pushSeparator = (include) => {
if (include && !include.call(this)) {
return
}
items.push(this._uiGen.createDockSeparator())
}
pushField(DOCK_BOOKMARK_SEARCH)
pushField(MEDIA_AUTO_DOWNLOAD)
pushField(DOCK_DOWNLOAD_MEDIA)
pushField(SEARCH_AUTO_NEXT)
pushSeparator(function() { return this.isDownloadManagerEnabled() })
pushField('dock-tag-discovery-mode')
pushField('dock-selection-mode')
pushField('dock-add-to-queue')
pushField('dock-download-start-pause')
pushField(OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER)
pushSeparator(function() { return this.isDownloadManagerEnabled() })
pushField(OPTION_DISABLE_COMPLIANCE_VALIDATION)
pushField(OPTION_ENABLE_EXPLORED_TAGS_TRACKER)
pushField(OPTION_HIDE_OLDER_POSTS)
return items
}
/**
* @protected
* @return {HTMLElement}
*/
_createSettingsBackupRestoreFormActions()
{
let restoreInput = this._uiGen.createFormGroupInput('file')
restoreInput.id = 'restore-settings'
restoreInput.placeholder = 'Browse for settings file...'
return this._uiGen.createFormActions([
this._uiGen.createFormButton('Backup Configuration', 'Download configuration file.', () => this._onBackupSettings()),
this._uiGen.createSeparator(),
restoreInput,
this._uiGen.createFormButton('Restore Configuration', 'Restore configuration from the selected file.', () => this._onRestoreSettings()),
this._uiGen.createSeparator(),
this._uiGen.createFormButton(
'Clear Download Ledger',
'Delete all remembered download ids from IndexedDB. Hide Downloaded Media and Skip Duplicate Downloads will treat prior saves as unseen until they are downloaded again.',
() => this._onClearDownloadLedger(),
),
this._uiGen.createSeparator(),
this._uiGen.createFormButton(
'Clear Database',
'Delete this script’s entire IndexedDB store (settings, bookmarks, download memory, tags, and queues). The page reloads so the database can be rebuilt from scratch.',
() => this._onClearScriptDatabase(),
),
], 'bv-flex-column')
}
/**
* @private
*/
_onClearDownloadLedger()
{
if (this._configurationManager.isIdbBlocked()) {
alert('IndexedDB is unavailable — the download ledger cannot be cleared.')
return
}
if (!this._getDownloadDuplicateLedgerField()) {
alert('Download ledger is not configured for this script.')
return
}
if (!confirm(
'Clear the download ledger?\n\n' +
'This permanently deletes every remembered download id. Previously downloaded posts can be downloaded again, and Hide Downloaded Media will stop hiding them until they are recorded again.',
)) {
return
}
void this.clearDownloadDuplicateLedger().
then(() => {
alert('Download ledger cleared.')
this._validateCompliance()
}).
catch((error) => {
console.error(error)
alert('Failed to clear the download ledger. See the browser console for details.')
})
}
/**
* @private
*/
_onClearScriptDatabase()
{
if (this._configurationManager.isIdbBlocked()) {
alert('IndexedDB is unavailable — the database cannot be cleared.')
return
}
if (!confirm(
'Clear this script’s entire database?\n\n' +
'Settings, bookmarks, download memory, tags, and download queues will be permanently deleted. ' +
'The page will reload so the script can rebuild an empty database.',
)) {
return
}
void this.clearScriptDatabase().
catch((error) => {
console.error(error)
alert('Failed to clear the database. See the browser console for details.')
})
}
/**
* @private
*/
_setupUI()
{
this.configureDock({
orientations: ['bottom', 'right'],
defaultOrientation: 'bottom',
scriptName: 'Rule34.xxx - Search, Download & UI Enhancements',
showBranding: false,
})
if (FORCE_DOCK_MIGRATION_STATUS) {
this.setForceDockMigrationStatus(true)
}
this._setupDownloadManager()
let statistics = []
if (this.isPage('search')) {
statistics = [
this._uiGen.createStatisticsFormGroup(FILTER_TAG_BLACKLIST, 'Tag Blacklist'),
this._uiGen.createStatisticsFormGroup(FILTER_EXPLORED_TAGS, 'Explored Tags Tracker'),
this._uiGen.createStatisticsFormGroup(OPTION_HIDE_OLDER_POSTS, 'Hide Older Posts'),
this._uiGen.createStatisticsFormGroup(OPTION_HIDE_DOWNLOADED_MEDIA, 'Hide Downloaded Media'),
]
}
let filtersTab = [
this._configurationManager.createElement(DEFAULTS_TAGS),
this._uiGen.createSeparator(),
this._configurationManager.createElement(OPTION_ENABLE_RESOLUTION),
this._configurationManager.createElement(DEFAULTS_WIDTH),
this._configurationManager.createElement(DEFAULTS_HEIGHT),
this._uiGen.createSeparator(),
this._configurationManager.createElement(FILTER_TAG_BLACKLIST),
this._configurationManager.createElement(FILTER_EXPLORED_TAGS),
this._uiGen.createSeparator(),
this._configurationManager.createElement(LAST_ID),
]
let globalTab = [
this._createSettingsBackupRestoreFormActions(),
]
this._userInterface = [
this._uiGen.createTabsSection(['Bookmarks', 'Filters', 'Downloads', 'Behaviours', 'Toolbox', 'Donate'], [
Utilities.appendChildren(this._uiGen.createTabPanel('Bookmarks', true),
this._configurationManager.createElement(BOOKMARKS)),
Utilities.appendChildren(this._uiGen.createTabPanel('Filters'), filtersTab),
Utilities.appendChildren(this._uiGen.createTabPanel('Downloads'), [
this._configurationManager.createElement(OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT),
this._uiGen.createSeparator(),
this._createPatternGroup(DOWNLOAD_FOLDER),
this._createPatternGroup(DOWNLOAD_SUBFOLDER_PATTERN),
this._createPatternGroup(DOWNLOAD_FILENAME_PATTERN),
this._uiGen.createSharedPatternTokenBuilder(DOWNLOAD_PATTERN_SECTIONS, DOWNLOAD_PATTERN_SEPARATORS),
this._uiGen.createSeparator(),
this._configurationManager.createElement(DOWNLOAD_STRIP_CHARACTER_SERIES),
this._uiGen.createSeparator(),
this._configurationManager.createElement(DOWNLOAD_TAG_SUBSTITUTIONS),
this._configurationManager.createElement(DOWNLOAD_TAG_IGNORE),
]),
this.createBehavioursTabPanel(),
Utilities.appendChildren(this._uiGen.createTabPanel('Toolbox'), globalTab),
this.createDonateTabPanel(),
]),
this._uiGen.createBottomSection([
...statistics,
...(statistics.length ? [this._uiGen.createSeparator()] : []),
this._createSettingsFormActions(),
]),
]
}
/**
* @private
*/
_setupEvents()
{
this.onConfigurationChange((event) => {
this._syncDiscoveryTagTypesFromPatterns()
let source = event?.source ?? 'all'
if (source === 'tags') {
this._refreshTagActionSurfaces(event?.detail ?? null)
} else if (source === 'bookmarks' || source === 'settings' || source === 'all') {
this._refreshTagActionSurfaces(null)
}
if (this.isPage('search') && !this._redirecting &&
(source === 'settings' || source === 'all' || source === 'tags')) {
this._refreshSearchInputDisplay()
}
})
this.onTagSubstitutionUiChange(() => {
// Link-mode UI has no tags notify yet — refresh visible chrome.
this._refreshTagActionSurfaces(null)
})
this._forPage('search', () => {
this._onBeforeUIBuild.push(() => {
this._handleDefaultInjection()
if (!this._redirecting) {
this._setupSearchInputHook()
}
})
})
this._forPage('media', () => {
this._onBeforeUIBuild.push(() => {
// Config is loaded; sync pins before auto-download can open Tag Discovery.
this._syncDiscoveryTagTypesFromPatterns()
if (this._getConfig(MEDIA_AUTO_DOWNLOAD)) {
this._handleDownloadMedia()
}
})
window.addEventListener('pagehide', () => _releaseSuppressedMediaCapture())
})
this._onAfterUIBuild.push(() => {
this._syncDiscoveryTagTypesFromPatterns()
this._setupTagSidebarActions()
let section = this._uiGen.getSelectedSection()
if (section) {
section.userScript = this
}
this._patchPanelResizer()
this._patchPatternTokenBuilder()
this._uiGen.patchStackedPatternFieldGroups(this._configurationManager, [DOWNLOAD_FOLDER, DOWNLOAD_FILENAME_PATTERN, DOWNLOAD_SUBFOLDER_PATTERN])
this._uiGen.patchRangeInputLayout()
if (!this._searchInputHooked) {
this._setupSearchInputHook()
}
if (!this._redirecting) {
this._refreshSearchInputDisplay()
}
this._refreshBookmarkPanel()
})
this._forPage('search', () => {
this._onAfterComplianceRun.push(() => this._handleSearchAutoNext())
document.addEventListener('keydown', (event) => this._handleSearchKeyboardNavigation(event))
let resyncHideDownloaded = () => {
if (document.visibilityState !== 'visible') {
return
}
if (this._hideDownloadedResyncTimer) {
clearTimeout(this._hideDownloadedResyncTimer)
}
this._hideDownloadedResyncTimer = setTimeout(() => {
this._hideDownloadedResyncTimer = null
this._validateCompliance()
}, 400)
}
document.addEventListener('visibilitychange', resyncHideDownloaded)
window.addEventListener('focus', resyncHideDownloaded)
})
}
}
/**
* Seeds rule34xxx IndexedDB documents during first-time setup.
*
* @param {BrazenStorageRepositories} repos
* @param {BrazenConfigurationManager} cm
* @return {Promise<void>}
*/
async function rule34xxxSetupStorage(repos, cm)
{
void cm
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'}],
})
}
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: [
{entryId: 2, name: 'author', label: 'Artist', sortOrder: 0},
{entryId: 3, name: 'copyright', label: 'Series', sortOrder: 1},
{entryId: 4, name: 'character', label: 'Characters', sortOrder: 2},
{entryId: 5, name: 'meta', label: 'Meta', sortOrder: 3},
{entryId: 6, name: 'general', label: 'General', sortOrder: 4},
{entryId: 7, name: 'artist', label: 'Artist', apiEntryId: 1, sidebarClass: 'artist', aliasOfEntryIds: [2]},
],
})
}
}
/** Build id for Tampermonkey local-script checks (must match this workspace file). */
const RULE34XXX_BUILD = 'local-4.1.0'
void (new Rule34xxxSearchEnhancer).init()