您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
Base class for search enhancement scripts
当前为
此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.greasyfork.icu/scripts/375557/889080/Brazen%20Base%20Search%20Enhancer.js
// ==UserScript== // @name Brazen Base Search Enhancer // @namespace brazenvoid // @version 2.7.0 // @author brazenvoid // @license GPL-3.0-only // @description Base class for search enhancement scripts // ==/UserScript== const FILTER_DURATION_RANGE = 'Duration' const FILTER_PERCENTAGE_RATING_RANGE = 'Rating' const FILTER_TEXT_BLACKLIST = 'Blacklist' const FILTER_TEXT_SEARCH = 'Search' const FILTER_TEXT_SANITIZATION = 'Text Sanitization Rules' const FILTER_TEXT_WHITELIST = 'Whitelist' const FILTER_UNRATED = 'Unrated' const ITEM_NAME_DOM_KEY = 'scriptItemName' const ITEM_PROCESSED_ONCE_DOM_KEY = 'scriptProcessedOnce' const CONFIG_PAGINATOR_LIMIT = 'Pagination Limit' const CONFIG_PAGINATOR_THRESHOLD = 'Pagination Threshold' const OPTION_ALWAYS_SHOW_SETTINGS_PANE = 'Always Show Settings Pane' const OPTION_DISABLE_COMPLIANCE_VALIDATION = 'Disable All Filters' class BrazenPaginator { /** * @callback PaginatorAfterPaginationEventHandler * @param {BrazenPaginator} paginator */ /** * @callback PaginatorGetPageNoFromUrlHandler * @param {string} pageUrl * @param {BrazenPaginator} paginator */ /** * @callback PaginatorGetPageUrlFromPageNoHandler * @param {number} pageNo * @param {BrazenPaginator} paginator */ /** * @callback PaginatorGetPaginationElementForPageNoHandler * @param {number} pageNo * @param {BrazenPaginator} paginator */ /** * @param {JQuery} paginationWrapper * @param {JQuery.Selector} listSelector * @param {JQuery.Selector} itemClassesSelector * @param {string } lastPageUrl * @return {BrazenPaginator} */ static create (paginationWrapper, listSelector, itemClassesSelector, lastPageUrl) { return (new BrazenPaginator).configure(paginationWrapper, listSelector, itemClassesSelector, lastPageUrl) } /** * */ constructor () { /** * @type {number} * @private */ this._currentPageNo = 0 /** * @type {JQuery.Selector} * @private */ this._itemClassesSelector = '' /** * @type {number} * @private */ this._lastPageNo = 0 /** * @type {string} * @private */ this._lastPageUrl = '' /** * @type {JQuery.Selector} * @private */ this._listSelector = '' /** * @type {boolean} * @private */ this._pageConcatenated = false /** * @type {number} * @private */ this._paginatedPageNo = 0 /** * @type {JQuery} * @private */ this._paginationWrapper = null /** * @type {JQuery} * @private */ this._targetElement = null // Events and callbacks /** * @type {PaginatorAfterPaginationEventHandler} * @private */ this._onAfterPagination = null /** * @type {PaginatorGetPageNoFromUrlHandler} * @private */ this._onGetPageNoFromUrl = null /** * @type {PaginatorGetPageUrlFromPageNoHandler} * @private */ this._onGetPageUrlFromPageNo = null /** * @type {PaginatorGetPaginationElementForPageNoHandler} * @private */ this._onGetPaginationElementForPageNo = null } _conformUIToNewPaginatedState () { if (this._pageConcatenated) { this._pageConcatenated = false let currentPageElement = this.getPaginationElementForPageNo(this._currentPageNo) let newSubsequentPageNo = this._paginatedPageNo + 1 let newSubsequentPageNoUrl = this.getPageUrlFromPageNo(newSubsequentPageNo) // Mutate current page no element to show paginated page numbers currentPageElement.text(this._currentPageNo + '-' + this._paginatedPageNo) // Get next pages' pagination elements let currentNextPageElements = currentPageElement.nextAll() if (this._paginatedPageNo === this._lastPageNo) { // Delete all pagination elements if last page is paginated currentNextPageElements.remove() } else { // Determine whether the paginated page immediately precedes the last page if (newSubsequentPageNo !== this._lastPageNo) { // If not so, determine whether pagination element for the page following the paginated page exists let newSubsequentPageElement = this.getPaginationElementForPageNo(newSubsequentPageNo) if (!newSubsequentPageElement.length) { // If it does not exist then try getting the old next page no element let oldSubsequentPageElement = this.getPaginationElementForPageNo(this._currentPageNo + 1) if (oldSubsequentPageElement.length) { // If it does exist then mutate it for this purpose oldSubsequentPageElement.attr('href', newSubsequentPageNoUrl).text(newSubsequentPageNo) } else { // If even that does not exist, then clone the less desirable alternative; the last page element and mutate it to this use let lastPageElement = this.getPaginationElementForPageNo(this._lastPageNo) lastPageElement.clone().insertAfter(currentPageElement).attr('href', newSubsequentPageNoUrl).text(newSubsequentPageNo) } } // Remove any other pagination elements for already paginated pages currentNextPageElements.each((index, element) => { let paginationLink = $(element) if (this.getPageNoFromUrl(paginationLink.attr('href')) <= this._paginatedPageNo) { paginationLink.remove() } }) } } Utilities.callEventHandler(this._onAfterPagination, [this]) } } /** * @param {number} threshold * @param {number} limit * @private */ _loadAndParseNextPage (threshold, limit) { let lastPageHasNotBeenReached = this._paginatedPageNo < this._lastPageNo let paginationLimitHasNotBeenMet = limit > 0 && (this._paginatedPageNo - this._currentPageNo) < limit let compliantItemsAreLessThanTheThreshold = this._targetElement.find(this._itemClassesSelector + ':not(.noncompliant-item)').length < threshold if (lastPageHasNotBeenReached && paginationLimitHasNotBeenMet && compliantItemsAreLessThanTheThreshold) { this._sandbox.load(this.getPageUrlFromPageNo(++this._paginatedPageNo) + ' ' + this._listSelector, '', () => { this._pageConcatenated = true this._sandbox.find(this._itemClassesSelector).insertAfter(this._targetElement.find(this._itemClassesSelector + ':last')) this._sandbox.empty() }) } else { this._conformUIToNewPaginatedState() } } /** * @param {JQuery} paginationWrapper * @param {JQuery.Selector} listSelector * @param {JQuery.Selector} itemClassesSelector * @param {string } lastPageUrl * @return {BrazenPaginator} */ configure (paginationWrapper, listSelector, itemClassesSelector, lastPageUrl) { this._lastPageUrl = lastPageUrl this._listSelector = listSelector this._itemClassesSelector = itemClassesSelector this._paginationWrapper = paginationWrapper return this } getCurrentPageNo () { return this._currentPageNo } getLastPageNo () { return this._lastPageNo } getListSelector () { return this._listSelector } /** * @param {string} pageUrl * @return {number} */ getPageNoFromUrl (pageUrl) { return Utilities.callEventHandlerOrFail('onGetPageNoFromUrl', this._onGetPageNoFromUrl, [pageUrl, this]) } /** * @param {number} pageNo * @return {string} */ getPageUrlFromPageNo (pageNo) { return Utilities.callEventHandlerOrFail('onGetPageUrlFromPageNo', this._onGetPageUrlFromPageNo, [pageNo, this]) } /** * @param {number} pageNo * @return {JQuery} */ getPaginationElementForPageNo (pageNo) { return Utilities.callEventHandlerOrFail('onGetPaginationElementForPageNo', this._onGetPaginationElementForPageNo, [pageNo, this]) } getPaginatedPageNo () { return this._paginatedPageNo } getPaginationWrapper () { return this._paginationWrapper } initialize () { this._currentPageNo = this.getPageNoFromUrl(window.location.href) this._lastPageNo = this.getPageNoFromUrl(this._lastPageUrl) this._paginatedPageNo = this._currentPageNo this._sandbox = $('<div id="brazen-paginator-sandbox" hidden/>').appendTo('body') this._targetElement = $(this._listSelector + ':first') return this } /** * @param {PaginatorAfterPaginationEventHandler} handler * @return {this} */ onAfterPagination (handler) { this._onAfterPagination = handler return this } /** * @param {PaginatorGetPageNoFromUrlHandler} handler * @return {this} */ onGetPageNoFromUrl (handler) { this._onGetPageNoFromUrl = handler return this } /** * @param {PaginatorGetPageUrlFromPageNoHandler} handler * @return {this} */ onGetPageUrlFromPageNo (handler) { this._onGetPageUrlFromPageNo = handler return this } /** * @param {PaginatorGetPaginationElementForPageNoHandler} handler * @return {this} */ onGetPaginationElementForPageNo (handler) { this._onGetPaginationElementForPageNo = handler return this } run (threshold, limit) { if (this._paginationWrapper.length && threshold) { this._loadAndParseNextPage(threshold, limit) } return this } } class BrazenBaseSearchEnhancer { /** * @typedef {{configKey: string, validate: SearchEnhancerFilterValidationCallback, comply: SearchEnhancerFilterComplianceCallback}} ComplianceFilter */ /** * @callback SearchEnhancerFilterValidationCallback * @param {*} configValues * @return boolean */ /** * @callback SearchEnhancerFilterComplianceCallback * @param {JQuery} item * @param {*} configValues * @return {*} */ /** * @return BrazenBaseSearchEnhancer */ static initialize () { BrazenBaseSearchEnhancer.throwOverrideError() } static throwOverrideError () { throw new Error('Method must be overridden.') } /** * @param {string} scriptPrefix * @param {string|string[]} itemClasses */ constructor (scriptPrefix, itemClasses) { /** * Array of item compliance filters ordered in intended sequence of execution * @type {ComplianceFilter[]} * @protected */ this._complianceFilters = [] /** * @type {string[]} * @protected */ this._itemClasses = Array.isArray(itemClasses) ? itemClasses : [itemClasses] /** * @type {string} * @protected */ this._itemClassesSelector = '.' + this._itemClasses.join(',.') /** * Pagination manager * @type BrazenPaginator|null * @protected */ this._paginator = null /** * @type {string} * @protected */ this._scriptPrefix = scriptPrefix /** * @type {StatisticsRecorder} * @protected */ this._statistics = new StatisticsRecorder(this._scriptPrefix) /** * @type {BrazenUIGenerator} * @protected */ this._uiGen = new BrazenUIGenerator(this._scriptPrefix) /** * Local storage store with defaults * @type {BrazenConfigurationManager} * @protected */ this._configurationManager = BrazenConfigurationManager.create(this._uiGen). addFlagField(OPTION_DISABLE_COMPLIANCE_VALIDATION, 'Disables all search filters.'). addFlagField(OPTION_ALWAYS_SHOW_SETTINGS_PANE, 'Always show configuration interface.') // Events /** * Operations to perform after script initialization * @type {Function} * @protected */ this._onAfterInitialization = null /** * Operations to perform after UI generation * @type {Function} * @protected */ this._onAfterUIBuild = null /** * Operations to perform before compliance validation. This callback can also be used to skip compliance validation by returning false. * @type {null} * @protected */ this._onBeforeCompliance = null /** * Operations to perform before UI generation * @type {Function} * @protected */ this._onBeforeUIBuild = null /** * Operations to perform after compliance checks, the first time a item is retrieved * @type {Function} * @protected */ this._onFirstHitAfterCompliance = null /** * Operations to perform before compliance checks, the first time a item is retrieved * @type {Function} * @protected */ this._onFirstHitBeforeCompliance = null /** * Get item lists from the page * @type {Function} * @protected */ this._onGetItemLists = null /** * @type {Function} * @protected */ this._onGetItemName = null /** * Logic to hide a non-compliant item * @type {Function} * @protected */ this._onItemHide = (item) => { item.addClass('noncompliant-item') item.hide() } /** * Logic to show compliant item * @type {Function} * @protected */ this._onItemShow = (item) => { item.removeClass('noncompliant-item') item.show() } /** * Must return the generated settings section node * @type {Function} * @protected */ this._onUIBuild = null /** * Validate initiating initialization. * Can be used to stop script init on specific pages or vice versa * @type {Function} * @protected */ this._onValidateInit = () => true } /** * @param {string} configKey * @param {SearchEnhancerFilterValidationCallback} validationCallback * @param {SearchEnhancerFilterComplianceCallback} complianceCallback * @protected */ _addItemComplianceFilter (configKey, validationCallback, complianceCallback) { this._complianceFilters.push({ configKey: configKey, validate: validationCallback, comply: complianceCallback, }) } /** * @param {string} helpText * @protected */ _addItemBlacklistFilter (helpText) { this._configurationManager.addRulesetField( FILTER_TEXT_BLACKLIST, helpText, null, null, (rules) => Utilities.buildWholeWordMatchingRegex(rules)) this._addItemComplianceFilter( FILTER_TEXT_BLACKLIST, (value) => typeof value === 'object', (item, value) => item[0][ITEM_NAME_DOM_KEY].match(value) === null ) } /** * @param {JQuery.Selector} durationNodeSelector * @param {string|null} helpText * @protected */ _addItemDurationRangeFilter (durationNodeSelector, helpText = null) { this._configurationManager.addRangeField(FILTER_DURATION_RANGE, 0, 100000, helpText ?? 'Filter items by duration.') this._addItemComplianceFilter(FILTER_DURATION_RANGE, (range) => range.minimum > 0 || range.maximum > 0, (item, range) => { let duration = 0 let durationNode = item.find(durationNodeSelector) if (durationNode.length) { duration = durationNode.text().split(':') duration = (parseInt(duration[0]) * 60) + parseInt(duration[1]) } return duration === 0 ? !durationNode.length : Validator.isInRange(duration, range.minimum, range.maximum) }) } /** * @param {JQuery.Selector} ratingNodeSelector * @param {string|null} helpText * @param {string|null} unratedHelpText * @protected */ _addItemPercentageRatingRangeFilter (ratingNodeSelector, helpText = null, unratedHelpText = null) { this._configurationManager. addRangeField(FILTER_PERCENTAGE_RATING_RANGE, 0, 100000, helpText ?? 'Filter items by percentage rating.'). addFlagField(FILTER_UNRATED, unratedHelpText ?? 'Hide items with zero or no rating.') this._addItemComplianceFilter(FILTER_PERCENTAGE_RATING_RANGE, (range) => range.minimum > 0 || range.maximum > 0, (item, range) => { let rating = item.find(ratingNodeSelector) rating = rating.length === 0 ? 0 : parseInt(rating.text().replace('%', '')) return rating === 0 ? !this._configurationManager.getValue(FILTER_UNRATED) : Validator.isInRange(rating, range.minimum, range.maximum) }) } /** * @param {string} helpText * @protected */ _addItemTextSanitizationFilter (helpText) { this._configurationManager.addRulesetField(FILTER_TEXT_SANITIZATION, helpText, (rules) => { let sanitizationRules = {}, fragments, validatedTargetWords for (let sanitizationRule of rules) { if (sanitizationRule.includes('=')) { fragments = sanitizationRule.split('=') if (fragments[0] === '') { fragments[0] = ' ' } validatedTargetWords = Utilities.trimAndKeepNonEmptyStrings(fragments[1].split(',')) if (validatedTargetWords.length) { sanitizationRules[fragments[0]] = validatedTargetWords } } } return sanitizationRules }, (rules) => { let sanitizationRulesText = [] for (let substitute in rules) { sanitizationRulesText.push(substitute + '=' + rules[substitute].join(',')) } return sanitizationRulesText }, (rules) => { let optimizedRules = {} for (const substitute in rules) { optimizedRules[substitute] = Utilities.buildWholeWordMatchingRegex(rules[substitute]) } return optimizedRules }) } /** * @param {string|null} helpText * @protected */ _addItemTextSearchFilter (helpText = null) { this._configurationManager.addTextField( FILTER_TEXT_SEARCH, helpText ?? 'Show videos with these comma separated words in their names.') this._addItemComplianceFilter( FILTER_TEXT_SEARCH, (value) => value.length, (item, value) => item[0][ITEM_NAME_DOM_KEY].includes(value)) } /** * @param {string} helpText * @protected */ _addItemWhitelistFilter (helpText) { this._configurationManager.addRulesetField( FILTER_TEXT_WHITELIST, helpText, null, null, (rules) => Utilities.buildWholeWordMatchingRegex(rules)) } _addPaginationConfiguration () { this._configurationManager. addNumberField(CONFIG_PAGINATOR_LIMIT, 1, 50, 'Limit paginator to concatenate the specified number of maximum pages.'). addNumberField(CONFIG_PAGINATOR_THRESHOLD, 1, 1000, 'Make paginator ensure the specified number of minimum results.') } /** * Filters items as per settings * @param {JQuery} itemsList * @param {boolean} fromObserver * @protected */ _complyItemsList (itemsList, fromObserver = false) { let items = fromObserver ? itemsList.filter(this._itemClassesSelector) : itemsList.find(this._itemClassesSelector) items.each((index, element) => { let item = $(element) // Before compliance filtering if (typeof element[ITEM_PROCESSED_ONCE_DOM_KEY] === 'undefined') { element[ITEM_PROCESSED_ONCE_DOM_KEY] = false Utilities.callEventHandler(this._onFirstHitBeforeCompliance, [item]) element[ITEM_NAME_DOM_KEY] = Utilities.callEventHandlerOrFail('getItemName', this._onGetItemName, [item]) } // Compliance filtering let itemComplies = true if (!this._configurationManager.getValue(OPTION_DISABLE_COMPLIANCE_VALIDATION) && this._validateItemWhiteList(item) && Utilities.callEventHandler(this._onBeforeCompliance, [item], true) ) { let configField for (let complianceFilter of this._complianceFilters) { configField = this._configurationManager.getFieldOrFail(complianceFilter.configKey) if (complianceFilter.validate(configField.optimized ?? configField.value)) { itemComplies = complianceFilter.comply(item, configField.optimized ?? configField.value) this._statistics.record(complianceFilter.configKey, itemComplies) if (!itemComplies) { break } } } } itemComplies ? Utilities.callEventHandler(this._onItemShow, [item]) : Utilities.callEventHandler(this._onItemHide, [item]) // After compliance filtering if (!element[ITEM_PROCESSED_ONCE_DOM_KEY]) { Utilities.callEventHandler(this._onFirstHitAfterCompliance, [item]) element[ITEM_PROCESSED_ONCE_DOM_KEY] = true } }) this._statistics.updateUI() } /** * @protected */ _createSettingsFormActions () { return this._uiGen.createFormSection().append([ this._uiGen.createFormActions([ this._uiGen.createFormButton('Apply', 'Apply settings.', () => this._onApplyNewSettings()), this._uiGen.createFormButton('Save', 'Apply and update saved configuration.', () => this._onSaveSettings()), this._uiGen.createFormButton('Reset', 'Revert to saved configuration.', () => this._onResetSettings()), ]), ]) } /** * @protected */ _createSettingsBackupRestoreFormActions () { return this._uiGen.createFormSection().append([ this._uiGen.createFormActions([ this._uiGen.createFormButton('Backup', 'Backup settings to the clipboard.', () => this._onBackupSettings()), this._uiGen.createFormGroupInput('text').attr('id', 'restore-settings').attr('placeholder', 'Paste settings...'), this._uiGen.createFormButton('Restore', 'Restore backup settings.', () => this._onRestoreSettings()), ]), ]) } /** * @param {JQuery} UISection * @private */ _embedUI (UISection) { UISection.on('mouseleave', (event) => { if (!this._configurationManager.getValue(OPTION_ALWAYS_SHOW_SETTINGS_PANE)) { $(event.currentTarget).hide(300) } }) if (this._configurationManager.getValue(OPTION_ALWAYS_SHOW_SETTINGS_PANE)) { UISection.show() } this._uiGen.constructor.appendToBody(UISection) this._uiGen.constructor.appendToBody(this._uiGen.createSettingsShowButton('', UISection)) } /** * @private */ _onApplyNewSettings () { this._configurationManager.update() this._validateCompliance() } /** * @private */ _onBackupSettings () { navigator.clipboard.writeText(this._configurationManager.backup()). then(() => this._uiGen.updateStatus('Settings backed up to clipboard!')). catch(() => this._uiGen.updateStatus('Settings backup failed!')); } /** * @private */ _onResetSettings () { this._configurationManager.revertChanges() this._validateCompliance() } /** * @private */ _onRestoreSettings () { let settings = $('#restore-settings').val().trim() if (!settings) { this._uiGen.updateStatus('No Settings provided!', true) Utilities.sleep(3000).then(() => this._uiGen.resetStatus()) } else { try { this._configurationManager.restore(settings) this._uiGen.updateStatus('Settings restored!') this._validateCompliance() } catch (e) { this._uiGen.updateStatus('Settings restoration failed!') } } } /** * @private */ _onSaveSettings () { this._onApplyNewSettings() this._configurationManager.save() } /** * @protected */ _showNotLoggedInAlert () { alert('You need to be logged in to use this functionality') } /** * @param {boolean} firstRun * @protected */ _validateCompliance (firstRun = false) { let itemLists = Utilities.callEventHandler(this._onGetItemLists) if (!firstRun) { this._statistics.reset() itemLists.each((index, itemsList) => { this._complyItemsList($(itemsList)) }) } else { itemLists.each((index, itemList) => { let itemListObject = $(itemList) if (this._paginator && itemListObject.is(this._paginator.getListSelector())) { ChildObserver.create().onNodesAdded((itemsAdded) => { this._complyItemsList($(itemsAdded), true) this._paginator.run(this._configurationManager.getValue(CONFIG_PAGINATOR_THRESHOLD), this._configurationManager.getValue(CONFIG_PAGINATOR_LIMIT)) }).observe(itemList) } else { ChildObserver.create().onNodesAdded((itemsAdded) => this._complyItemsList($(itemsAdded), true)).observe(itemList) } this._complyItemsList(itemListObject) }) } if (this._paginator) { this._paginator.run(this._configurationManager.getValue(CONFIG_PAGINATOR_THRESHOLD), this._configurationManager.getValue(CONFIG_PAGINATOR_LIMIT)) } } /** * @param {JQuery} item * @return {boolean} * @protected */ _validateItemWhiteList (item) { let field = this._configurationManager.getField(FILTER_TEXT_WHITELIST) if (field) { let validationResult = field.value.length ? Validator.regexMatches(item[0][ITEM_NAME_DOM_KEY], field.optimized) : true this._statistics.record(FILTER_TEXT_WHITELIST, validationResult) return validationResult } return true } /** * Initialize the script and do basic UI removals */ init () { if (Utilities.callEventHandler(this._onValidateInit)) { this._configurationManager.initialize(this._scriptPrefix) if (this._paginator) { this._paginator.initialize() } Utilities.callEventHandler(this._onBeforeUIBuild) this._embedUI(Utilities.callEventHandler(this._onUIBuild)) Utilities.callEventHandler(this._onAfterUIBuild) this._configurationManager.updateInterface() this._validateCompliance(true) this._uiGen.updateStatus('Initial run completed.') Utilities.callEventHandler(this._onAfterInitialization) } } }