您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Base class for search enhancement scripts
当前为
此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.sleazyfork.org/scripts/416105/1114748/Brazen%20Base%20Search%20Enhancer.js
// ==UserScript== // @name Brazen Base Search Enhancer // @namespace brazenvoid // @version 2.10.1 // @author brazenvoid // @license GPL-3.0-only // @description Base class for search enhancement scripts // ==/UserScript== const ICON_RECYCLE = '♻'; // Preset filter configuration keys const CONFIG_PAGINATOR_LIMIT = 'Pagination Limit'; const CONFIG_PAGINATOR_THRESHOLD = 'Pagination Threshold'; const FILTER_DURATION_RANGE = 'Duration'; const FILTER_PERCENTAGE_RATING_RANGE = 'Rating'; const FILTER_SUBSCRIBED_VIDEOS = 'Hide Subscribed Videos'; 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 STORE_SUBSCRIPTIONS = 'Account Subscriptions'; // Item preset attributes const ITEM_ATTRIBUTE_PRESET_DURATION_RANGE = 'presetDurationRange'; const ITEM_ATTRIBUTE_PRESET_NAME = 'presetName'; const ITEM_ATTRIBUTE_PRESET_PERCENTAGE_RATING = 'presetPercentageRating'; const ITEM_PROCESSED_ONCE = 'scriptItemProcessedOnce'; // Configuration const OPTION_ALWAYS_SHOW_SETTINGS_PANE = 'Always Show Settings Pane'; const OPTION_DISABLE_COMPLIANCE_VALIDATION = 'Disable All Filters'; class BrazenBaseSearchEnhancer { /** * @typedef {{configKey: string, validate: SearchEnhancerFilterValidationCallback, comply: SearchEnhancerFilterComplianceCallback}} ComplianceFilter */ /** * @callback SearchEnhancerFilterValidationCallback * @param {*} configValues * @return boolean */ /** * @callback SearchEnhancerFilterComplianceCallback * @param {JQuery} item * @param {*} configValues * @return {*} */ /** * @callback SubscriptionsFilterExclusionsCallback * @return {boolean} */ /** * @callback SubscriptionsFilterUsernameCallback * @param {JQuery} item * @return {boolean|string} */ /** * @return BrazenBaseSearchEnhancer */ static initialize() { BrazenBaseSearchEnhancer.throwOverrideError(); } static throwOverrideError() { throw new Error('Method must be overridden.'); } /** * @param {Object} configuration * @param {string} configuration.scriptPrefix * @param {JQuery.Selector} configuration.itemPageDeepAnalysisSelector * @param {JQuery.Selector} configuration.itemPageLinkSelector * @param {string} configuration.itemSelectors * @param {boolean} configuration.isUserLoggedIn */ constructor(configuration) { /** * Array of item compliance filters ordered in intended sequence of execution * @type {ComplianceFilter[]} * @private */ this._complianceFilters = []; /** * @type {boolean} * @private */ this._isUserLoggedIn = configuration.isUserLoggedIn; /** * @type {string} * @private */ this._itemClassesSelector = configuration.itemSelectors; /** * Pagination manager * @type BrazenPaginator|null * @private */ this._paginator = null; /** * @type {string} * @private */ this._scriptPrefix = configuration.scriptPrefix; /** * @type {BrazenItemAttributesResolver} * @protected */ this._itemAttributesResolver = new BrazenItemAttributesResolver(configuration.itemPageLinkSelector, configuration.itemPageDeepAnalysisSelector); /** * @type {StatisticsRecorder} * @protected */ this._statistics = new StatisticsRecorder(this._scriptPrefix); /** * @type {BrazenSubscriptionsLoader} * @protected */ this._subscriptionsLoader = new BrazenSubscriptionsLoader( (status) => this._uiGen.updateStatus(status), (subscriptions) => { this._configurationManager.getField(STORE_SUBSCRIPTIONS).value = subscriptions.length ? '"' + subscriptions.join('""') + '"' : ''; this._configurationManager.save(); $('#subscriptions-loader').prop('disabled', false); }); /** * @type {JQuery<HTMLElement> | jQuery | HTMLElement} * @protected */ this._syncConfigButton = $('<button id="brazen-sync-config-btn" style="position: fixed"></button>'). text(ICON_RECYCLE). hide(). appendTo($('body')). on('click', () => { this._onResetSettings(); this._syncConfigButton.hide(); }); /** * @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.') .onExternalConfigurationChange(() => this._syncConfigButton.show()); // 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} * @param {JQuery} item * @protected */ this._onFirstHitAfterCompliance = null; /** * Operations to perform before compliance checks, the first time a item is retrieved * @type {Function} * @param {JQuery} item * @protected */ this._onFirstHitBeforeCompliance = null; /** * Get item lists from the page * @type {Function} * @protected */ this._onGetItemLists = null; /** * Get item name from its node * @type {Function} * @param {JQuery} item * @protected */ this._onGetItemName = null; /** * Logic to hide a non-compliant item * @type {Function} * @param {JQuery} item * @protected */ this._onItemHide = (item) => { item.addClass('noncompliant-item'); item.hide(); }; /** * Logic to show compliant item * @type {Function} * @param {JQuery} item * @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} helpText * @protected */ _addItemBlacklistFilter(helpText) { this._configurationManager.addRulesetField( FILTER_TEXT_BLACKLIST, 3, helpText, null, null, (rules) => Utilities.buildWholeWordMatchingRegex(rules) ?? ''); this._addItemComplexComplianceFilter( FILTER_TEXT_BLACKLIST, (value) => value !== '', (item, value) => this.getItemAttribute(item, ITEM_ATTRIBUTE_PRESET_NAME).match(value) === null, ); } /** * @param {string} configKey * @param {SearchEnhancerFilterValidationCallback|null} validationCallback * @param {SearchEnhancerFilterComplianceCallback} complianceCallback * @protected */ _addItemComplexComplianceFilter(configKey, validationCallback, complianceCallback) { this._addItemComplianceFilter(configKey, complianceCallback, validationCallback); } /** * @param {string} configKey * @param {SearchEnhancerFilterComplianceCallback|string} action * @param {SearchEnhancerFilterValidationCallback|null} validationCallback * @protected */ _addItemComplianceFilter(configKey, action, validationCallback = null) { let configType = this._configurationManager.getField(configKey).type; if (typeof action === 'string') { let attributeName = action; switch (configType) { case CONFIG_TYPE_FLAG: action = (item) => this.getItemAttribute(item, attributeName); break; case CONFIG_TYPE_RANGE: action = (item, range) => Validator.isInRange(this.getItemAttribute(item, attributeName), range.minimum, range.maximum); break; default: throw new Error('Associated config type requires explicit action callback definition.'); } } if (validationCallback === null) { switch (configType) { case CONFIG_TYPE_FLAG: case CONFIG_TYPE_RADIOS_GROUP: case CONFIG_TYPE_SELECT: validationCallback = (value) => value; break; case CONFIG_TYPE_CHECKBOXES_GROUP: validationCallback = (valueKeys) => valueKeys.length; break; case CONFIG_TYPE_NUMBER: validationCallback = (value) => value > 0; break; case CONFIG_TYPE_RANGE: validationCallback = (range) => range.minimum > 0 || range.maximum > 0; break; case CONFIG_TYPE_RULESET: validationCallback = (rules) => rules.length; break; case CONFIG_TYPE_TEXT: validationCallback = (value) => value.length; break; default: throw new Error('Associated config type requires explicit validation callback definition.'); } } this._complianceFilters.push({ configKey: configKey, validate: validationCallback, comply: action, }); } /** * @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._itemAttributesResolver.addAttribute(ITEM_ATTRIBUTE_PRESET_DURATION_RANGE, (item) => { let duration = 0; let durationNode = item.find(durationNodeSelector); if (durationNode.length) { duration = durationNode.text().split(':'); duration = (parseInt(duration[0]) * 60) + parseInt(duration[1]); } if (duration === 0 && !durationNode.length) { duration = -1; } return duration; }); this._addItemComplianceFilter(FILTER_DURATION_RANGE, (item, range) => { let duration = this.getItemAttribute(item, ITEM_ATTRIBUTE_PRESET_DURATION_RANGE); return duration > 0 ? Validator.isInRange(duration, range.minimum, range.maximum) : duration === -1; }); } /** * @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._itemAttributesResolver.addAttribute(ITEM_ATTRIBUTE_PRESET_PERCENTAGE_RATING, (item) => { let rating = item.find(ratingNodeSelector); return rating.length === 0 ? 0 : parseInt(rating.text().replace('%', '')); }); this._addItemComplianceFilter(FILTER_PERCENTAGE_RATING_RANGE, (item, range) => { let rating = this.getItemAttribute(item, ITEM_ATTRIBUTE_PRESET_PERCENTAGE_RATING); 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, 2, 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, (item, value) => this.getItemAttribute(item, ITEM_ATTRIBUTE_PRESET_NAME).includes(value)); } /** * @param {string} helpText * @protected */ _addItemWhitelistFilter(helpText) { this._configurationManager.addRulesetField( FILTER_TEXT_WHITELIST, 3, helpText, null, null, (rules) => Utilities.buildWholeWordMatchingRegex(rules)); } // /** // * @protected // */ // _addItemCustomWatchedFilters () // { // this._configurationManager. // addFlagField(FILTER_VIEWED_VIDEOS, 'Tracks and hides all items present on opened pages on next page load. Should be purged regularly.'). // addFlagField(FILTER_WATCHED_VIDEOS, 'Tracks and hides 3,000 recent seen items.'). // addTextField(STORE_VIEWED_ADDRESSES, ''). // addTextField(STORE_WATCHED_ADDRESSES, '') // } _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.'); } /** * @param {{}} options * @param {{}} options.filter * @param {SubscriptionsFilterExclusionsCallback} options.filter.exclusionsCallback Add page exclusions here * @param {SubscriptionsFilterUsernameCallback} options.filter.getItemUsername Return username of the item or return false to skip * @param {{}} options.loader * @param {JQuery.Selector} options.loader.subscriptionNameSelector * @param {string} options.loader.subscriptionsPageUrl * @param {JQuery.Selector} options.loader.subsectionSelector * @param {SubscriptionLoaderGetPageCountCallback} options.loader.getPageCount * @param {SubscriptionLoaderGetPageUrlCallback} options.loader.getPageUrl * @protected */ _addSubscriptionsFilter(options) { this._configurationManager. addFlagField(FILTER_SUBSCRIBED_VIDEOS, 'Hide videos from subscribed channels.'). addTextField(STORE_SUBSCRIPTIONS, 'Recorded subscription accounts.'); this._subscriptionsLoader.baseUrl = options.loader.subscriptionsPageUrl; this._subscriptionsLoader.getPageCount = options.loader.getPageCount; this._subscriptionsLoader.getPageUrl = options.loader.getPageUrl; this._subscriptionsLoader.onSubscriptionsGathered = (subscriptions) => { this._configurationManager.getField(STORE_SUBSCRIPTIONS).value = subscriptions.length ? '"' + subscriptions.join('""') + '"' : ''; this._configurationManager.save(); $('#subscriptions-loader').prop('disabled', false); }; this._subscriptionsLoader.onProgressUpdate = (status) => this._uiGen.updateStatus(status); this._subscriptionsLoader.subscriptionNameSelector = options.loader.subscriptionNameSelector; this._subscriptionsLoader.subsectionSelector = options.loader.subsectionSelector; this._addItemComplexComplianceFilter( FILTER_SUBSCRIBED_VIDEOS, (value) => value && this._isUserLoggedIn && options.filter.exclusionsCallback(), (item) => { let username = options.filter.getItemUsername(item); return username === false ? true : !(new RegExp('"([^"]*' + username + '[^"]*)"')).test( this._configurationManager.getValue(STORE_SUBSCRIPTIONS)); }); } /** * @param item * @private */ _complyItem(item) { 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]); item.css('opacity', 'unset'); } /** * 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.css('opacity', 0.5).each((index, element) => { let item = $(element); // First run processing if (typeof element[ITEM_PROCESSED_ONCE] === 'undefined') { element[ITEM_PROCESSED_ONCE] = false; this._itemAttributesResolver.resolveAttributes(item); Utilities.callEventHandler(this._onFirstHitBeforeCompliance, [item]); } // Compliance filtering this._complyItem(item); // After first run processing if (!element[ITEM_PROCESSED_ONCE]) { Utilities.callEventHandler(this._onFirstHitAfterCompliance, [item]); element[ITEM_PROCESSED_ONCE] = true; } }); this._statistics.updateUI(); } /** * @protected * @return {JQuery[]} */ _createPaginationControls() { return [this._configurationManager.createElement(CONFIG_PAGINATOR_THRESHOLD), this._configurationManager.createElement(CONFIG_PAGINATOR_LIMIT)]; } /** * @protected * @return {JQuery} */ _createSettingsBackupRestoreFormActions() { return this._uiGen.createFormSection('Backup & Restore').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()), ], 'single-column-layout'), ]); } /** * @protected * @return {JQuery} */ _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 * @return {JQuery} */ _createSubscriptionLoaderControls() { let button = this._uiGen.createFormButton('Load Subscriptions', 'Makes a copy of your subscriptions in cache for related filters.', (event) => { if (this._isUserLoggedIn) { $(event.currentTarget).prop('disabled', true); this._subscriptionsLoader.run(); } else { this._showNotLoggedInAlert(); } }); return button.attr('id', 'subscriptions-loader'); } /** * @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(this.getItemAttribute(item, ITEM_ATTRIBUTE_PRESET_NAME), field.optimized) : true; this._statistics.record(FILTER_TEXT_WHITELIST, validationResult); return validationResult; } return true; } /** * @param {JQuery} item * @param {string} attributeName * @returns {*} */ getItemAttribute(item, attributeName) { return this._itemAttributesResolver.getAttribute(item, attributeName); } /** * Initialize the script and do basic UI removals */ init() { if (Utilities.callEventHandler(this._onValidateInit)) { this._configurationManager.initialize(this._scriptPrefix); this._itemAttributesResolver. addAttribute(ITEM_ATTRIBUTE_PRESET_NAME, (item) => Utilities.callEventHandlerOrFail('getItemName', this._onGetItemName, [item])); 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); } } /** * @returns {boolean} */ isUserLoggedIn() { return this._isUserLoggedIn; } }