From bccb8669a917af1962363b4a48f49f2187a60b9e Mon Sep 17 00:00:00 2001 From: festoney8 Date: Tue, 30 Apr 2024 09:05:29 +0800 Subject: [PATCH] feat: popular page add quality filter, dimension filter --- CHANGELOG.md | 4 +- src/components/item.ts | 4 +- src/filters/videoFilter/agency/agency.ts | 27 +++++ src/filters/videoFilter/filters/core.ts | 38 +++++-- .../filters/subfilters/dimension.ts | 27 +++++ .../videoFilter/filters/subfilters/quality.ts | 44 ++++++++ .../videoFilter/pages/actions/action.ts | 78 +++++++++++++- src/filters/videoFilter/pages/popular.ts | 101 ++++++++++++++---- 8 files changed, 291 insertions(+), 32 deletions(-) create mode 100644 src/filters/videoFilter/filters/subfilters/dimension.ts create mode 100644 src/filters/videoFilter/filters/subfilters/quality.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b777e1f6..962c3ef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ ## 3.5.0 -- 新增:热门/每周必看/排行榜页 支持时长过滤 +- 新增:热门/每周必看/排行榜页 时长过滤 +- 新增:热门/每周必看/排行榜页 竖屏视频过滤 +- 新增:热门/每周必看/排行榜页 视频质量过滤(实验功能) - 新增:右键菜单中复制链接功能 ## 3.4.7 diff --git a/src/components/item.ts b/src/components/item.ts index a223b92e..5d5af31d 100644 --- a/src/components/item.ts +++ b/src/components/item.ts @@ -398,11 +398,11 @@ export class NumberItem implements IItem { constructor(private option: INumberItemOption) {} - /** 获取数值, 初次安装使用禁用值 */ + /** 获取数值, 初次安装使用默认值 */ getValue() { this.itemValue = GM_getValue(`BILICLEANER_${this.option.itemID}`) if (this.itemValue === undefined) { - this.itemValue = this.option.disableValue + this.itemValue = this.option.defaultValue this.setValue(this.itemValue) } } diff --git a/src/filters/videoFilter/agency/agency.ts b/src/filters/videoFilter/agency/agency.ts index d7bededb..a11f0fb2 100644 --- a/src/filters/videoFilter/agency/agency.ts +++ b/src/filters/videoFilter/agency/agency.ts @@ -1,5 +1,7 @@ import bvidFilterInstance from '../filters/subfilters/bvid' +import dimensionFilterInstance from '../filters/subfilters/dimension' import durationFilterInstance from '../filters/subfilters/duration' +import qualityFilterInstance from '../filters/subfilters/quality' import titleKeywordFilterInstance from '../filters/subfilters/titleKeyword' import titleKeywordWhitelistFilterInstance from '../filters/subfilters/titleKeywordWhitelist' import uploaderFilterInstance from '../filters/subfilters/uploader' @@ -45,6 +47,31 @@ class VideoFilterAgency { break } } + notifyQuality(event: string, value?: number) { + switch (event) { + case 'disable': + qualityFilterInstance.setStatus(false) + break + case 'enable': + qualityFilterInstance.setStatus(true) + break + case 'change': + if (typeof value === 'number') { + qualityFilterInstance.setParams(value) + } + break + } + } + notifyDimension(event: string) { + switch (event) { + case 'disable': + dimensionFilterInstance.setStatus(false) + break + case 'enable': + dimensionFilterInstance.setStatus(true) + break + } + } notifyBvid(event: string, value?: string | string[]) { switch (event) { case 'disable': diff --git a/src/filters/videoFilter/filters/core.ts b/src/filters/videoFilter/filters/core.ts index 3adc9034..ebf6554b 100644 --- a/src/filters/videoFilter/filters/core.ts +++ b/src/filters/videoFilter/filters/core.ts @@ -2,7 +2,9 @@ import settings from '../../../settings' import { debugVideoFilter as debug, error, log } from '../../../utils/logger' import { hideEle, isEleHide, showEle } from '../../../utils/tool' import bvidFilterInstance from './subfilters/bvid' +import dimensionFilterInstance from './subfilters/dimension' import durationFilterInstance from './subfilters/duration' +import qualityFilterInstance from './subfilters/quality' import titleKeywordFilterInstance from './subfilters/titleKeyword' import titleKeywordWhitelistFilterInstance from './subfilters/titleKeywordWhitelist' import uploaderFilterInstance from './subfilters/uploader' @@ -13,7 +15,7 @@ import uploaderWhitelistFilterInstance from './subfilters/uploaderWhitelist' export interface IVideoSubFilter { isEnable: boolean setStatus(status: boolean): void - setParams(value: string[] | number): void + setParams?(value: string[] | number): void addParam?(value: string): void check(value: string | boolean | number): Promise } @@ -24,7 +26,7 @@ export type VideoSelectorFunc = { bvid?: (video: HTMLElement) => string | null uploader?: (video: HTMLElement) => string | null coinLikeRatio?: (video: HTMLElement) => number | null - isVertical?: (video: HTMLElement) => boolean | null + dimension?: (video: HTMLElement) => boolean | null } interface VideoInfo { @@ -33,7 +35,7 @@ interface VideoInfo { uploader?: string | undefined bvid?: string | undefined coinLikeRatio?: number | undefined - isVertical?: boolean | undefined + dimension?: boolean | undefined // true横屏 false竖屏 } class CoreVideoFilter { @@ -48,6 +50,8 @@ class CoreVideoFilter { debug(`checkAll start`) try { const checkDuration = durationFilterInstance.isEnable && selectorFunc.duration !== undefined + const checkQuality = qualityFilterInstance.isEnable && selectorFunc.coinLikeRatio !== undefined + const checkDimension = dimensionFilterInstance.isEnable && selectorFunc.dimension !== undefined const checkTitleKeyword = titleKeywordFilterInstance.isEnable && selectorFunc.titleKeyword !== undefined const checkUploader = uploaderFilterInstance.isEnable && selectorFunc.uploader !== undefined const checkUploaderKeyword = uploaderKeywordFilterInstance.isEnable && selectorFunc.uploader !== undefined @@ -57,7 +61,15 @@ class CoreVideoFilter { const checkTitleKeywordWhitelist = titleKeywordWhitelistFilterInstance.isEnable && selectorFunc.titleKeyword !== undefined - if (!checkDuration && !checkTitleKeyword && !checkUploader && !checkBvid) { + if ( + !checkDuration && + !checkQuality && + !checkDimension && + !checkTitleKeyword && + !checkUploader && + !checkUploaderKeyword && + !checkBvid + ) { // 黑名单全部关闭时 恢复全部视频 videos.forEach((video) => showEle(video)) return @@ -76,6 +88,20 @@ class CoreVideoFilter { info.duration = duration } } + if (checkQuality) { + const ratio = selectorFunc.coinLikeRatio!(video) + if (ratio) { + blackTasks.push(qualityFilterInstance.check(ratio)) + info.coinLikeRatio = ratio + } + } + if (checkDimension) { + const dimension = selectorFunc.dimension!(video) + if (dimension !== null) { + blackTasks.push(dimensionFilterInstance.check(dimension)) + info.dimension = dimension + } + } if (checkBvid) { const bvid = selectorFunc.bvid!(video) if (bvid) { @@ -139,7 +165,7 @@ class CoreVideoFilter { // debug(_result) if (!isEleHide(video)) { log( - `hide video\nbvid: ${info.bvid}\ntime: ${info.duration}\nup: ${info.uploader}\ntitle: ${info.title}`, + `hide video\nbvid: ${info.bvid}\ntime: ${info.duration}\nup: ${info.uploader}\nratio: ${info.coinLikeRatio}\ntitle: ${info.title}`, ) } hideEle(video) @@ -152,7 +178,7 @@ class CoreVideoFilter { } else { if (!isEleHide(video)) { log( - `hide video\nbvid: ${info.bvid}\ntime: ${info.duration}\nup: ${info.uploader}\ntitle: ${info.title}`, + `hide video\nbvid: ${info.bvid}\ntime: ${info.duration}\nup: ${info.uploader}\nratio: ${info.coinLikeRatio}\ntitle: ${info.title}`, ) } hideEle(video) diff --git a/src/filters/videoFilter/filters/subfilters/dimension.ts b/src/filters/videoFilter/filters/subfilters/dimension.ts new file mode 100644 index 00000000..2b6d83bd --- /dev/null +++ b/src/filters/videoFilter/filters/subfilters/dimension.ts @@ -0,0 +1,27 @@ +import { IVideoSubFilter } from '../core' + +class DimensionFilter implements IVideoSubFilter { + isEnable = false + + setStatus(status: boolean) { + this.isEnable = status + } + + check(dimension: boolean): Promise { + return new Promise((resolve, reject) => { + if (!this.isEnable) { + resolve(`Dimension filter disable`) + } else { + if (dimension) { + resolve(`Dimension is horizontal`) + } else { + reject(`Dimension is vertical`) + } + } + }) + } +} + +// 单例 +const dimensionFilterInstance = new DimensionFilter() +export default dimensionFilterInstance diff --git a/src/filters/videoFilter/filters/subfilters/quality.ts b/src/filters/videoFilter/filters/subfilters/quality.ts new file mode 100644 index 00000000..89bf150f --- /dev/null +++ b/src/filters/videoFilter/filters/subfilters/quality.ts @@ -0,0 +1,44 @@ +import { IVideoSubFilter } from '../core' + +class QualityFilter implements IVideoSubFilter { + // 质量过滤阈值 + private threshold = 0 + isEnable = false + + setStatus(status: boolean) { + this.isEnable = status + } + + setParams(threshold: number) { + this.threshold = threshold + } + + // 根据coinLikeRatio计算视频质量, 参数源于爬虫数据拟合 + calcQuality = (ratio: number): number => { + const A = -1.201e1 + const B = 6.861e-1 + const C = 7.369e-2 + const D = 1.192e2 + const ans = (A - D) / (1 + Math.pow(ratio / C, B)) + D + return ans > 0 ? ans : 0 + } + + check(ratio: number): Promise { + return new Promise((resolve, reject) => { + if (!this.isEnable || this.threshold === 0) { + resolve(`Quality resolve, disable or 0`) + } else { + const score = this.calcQuality(ratio) + if (score > 0 && score > this.threshold) { + resolve(`Quality OK`) + } else { + reject(`Quality too bad`) + } + } + }) + } +} + +// 单例 +const qualityFilterInstance = new QualityFilter() +export default qualityFilterInstance diff --git a/src/filters/videoFilter/pages/actions/action.ts b/src/filters/videoFilter/pages/actions/action.ts index 5d03d4d6..7ecc3a44 100644 --- a/src/filters/videoFilter/pages/actions/action.ts +++ b/src/filters/videoFilter/pages/actions/action.ts @@ -4,6 +4,8 @@ import { debugVideoFilter as debug } from '../../../../utils/logger' import agencyInstance from '../../agency/agency' import bvidFilterInstance from '../../filters/subfilters/bvid' import durationFilterInstance from '../../filters/subfilters/duration' +import dimensionFilterInstance from '../../filters/subfilters/dimension' +import qualityFilterInstance from '../../filters/subfilters/quality' import titleKeywordFilterInstance from '../../filters/subfilters/titleKeyword' import titleKeywordWhitelistFilterInstance from '../../filters/subfilters/titleKeywordWhitelist' import uploaderFilterInstance from '../../filters/subfilters/uploader' @@ -13,9 +15,9 @@ import uploaderWhitelistFilterInstance from '../../filters/subfilters/uploaderWh // 定义各种黑名单功能、白名单功能的属性和行为 interface VideoFilterAction { statusKey: string - valueKey: string + valueKey?: string status: boolean - value: number | string | string[] + value?: number | string | string[] // 检测视频列表的函数 checkVideoList(fullSite: boolean): void blacklist?: WordList @@ -130,6 +132,78 @@ export class UploaderAction implements VideoFilterAction { } } +export class QualityAction implements VideoFilterAction { + statusKey: string + valueKey: string + checkVideoList: (fullSite: boolean) => void + status: boolean + value: number + + /** + * 视频质量过滤操作 + * @param statusKey 是否启用的GM key + * @param valueKey 存储数据的GM key + * @param checkVideoList 检测视频列表函数 + */ + constructor(statusKey: string, valueKey: string, checkVideoList: (fullSite: boolean) => void) { + this.statusKey = statusKey + this.valueKey = valueKey + this.checkVideoList = checkVideoList + this.status = GM_getValue(`BILICLEANER_${this.statusKey}`, false) + this.value = GM_getValue(`BILICLEANER_${this.valueKey}`, 20) + qualityFilterInstance.setStatus(this.status) + qualityFilterInstance.setParams(this.value) + } + enable() { + debug(`QualityAction enable`) + agencyInstance.notifyQuality('enable') + this.checkVideoList(true) + this.status = true + } + disable() { + debug(`QualityAction disable`) + agencyInstance.notifyQuality('disable') + this.checkVideoList(true) + this.status = false + } + change(value: number) { + debug(`QualityAction change ${value}`) + agencyInstance.notifyQuality('change', value) + this.checkVideoList(true) + } +} + +export class DimensionAction implements VideoFilterAction { + statusKey: string + checkVideoList: (fullSite: boolean) => void + status: boolean + + /** + * 视频横竖屏过滤操作 + * @param statusKey 是否启用的GM key + * @param valueKey 存储数据的GM key + * @param checkVideoList 检测视频列表函数 + */ + constructor(statusKey: string, checkVideoList: (fullSite: boolean) => void) { + this.statusKey = statusKey + this.checkVideoList = checkVideoList + this.status = GM_getValue(`BILICLEANER_${this.statusKey}`, false) + dimensionFilterInstance.setStatus(this.status) + } + enable() { + debug(`DimensionAction enable`) + agencyInstance.notifyDimension('enable') + this.checkVideoList(true) + this.status = true + } + disable() { + debug(`DimensionAction disable`) + agencyInstance.notifyDimension('disable') + this.checkVideoList(true) + this.status = false + } +} + export class BvidAction implements VideoFilterAction { statusKey: string valueKey: string diff --git a/src/filters/videoFilter/pages/popular.ts b/src/filters/videoFilter/pages/popular.ts index 3baf91ed..5d67cb6c 100644 --- a/src/filters/videoFilter/pages/popular.ts +++ b/src/filters/videoFilter/pages/popular.ts @@ -8,7 +8,9 @@ import { ContextMenu } from '../../../components/contextmenu' import { matchBvid, waitForEle } from '../../../utils/tool' import { BvidAction, + DimensionAction, DurationAction, + QualityAction, TitleKeywordAction, TitleKeywordWhitelistAction, UploaderAction, @@ -27,7 +29,7 @@ let isContextMenuBvidEnable = false if (isPagePopular()) { interface VInfo { duration: number - isVertical: boolean + dimension: boolean like: number coin: number } @@ -35,7 +37,7 @@ if (isPagePopular()) { const videoInfoMap = new Map() // hook fetch - let apiResp: Response | null = null + let apiResp: Response | undefined = undefined const origFetch = unsafeWindow.fetch unsafeWindow.fetch = (input, init?) => { if (typeof input === 'string' && input.includes('api.bilibili.com') && init?.method?.toUpperCase() === 'GET') { @@ -52,18 +54,19 @@ if (isPagePopular()) { // 解析API数据,存入map const parseResp = async () => { await apiResp - ?.json() + ?.clone() + .json() .then((json) => { json.data.list.forEach((v: any) => { const bvid = v.bvid - bvid && - !videoInfoMap.has(bvid) && + if (bvid && !videoInfoMap.has(bvid)) { videoInfoMap.set(bvid, { - duration: v.duration || 0, - isVertical: (v.dimension && v.dimension.height > v.dimension.width) || false, - like: (v.stat && v.stat.like) || 0, - coin: (v.stat && v.stat.coin) || 0, + duration: v.duration, + dimension: v.dimension.width > v.dimension.height, + like: v.stat.like, + coin: v.stat.coin, }) + } }) // debug('parse json complete, videoInfoMap size', videoInfoMap.size) }) @@ -71,7 +74,7 @@ if (isPagePopular()) { error('Error parsing JSON:', err) }) .finally(() => { - apiResp = null + apiResp = undefined }) } @@ -129,17 +132,18 @@ if (isPagePopular()) { } return null }, - isVertical: (video: Element): boolean | null => { + dimension: (video: Element): boolean | null => { const href = video.querySelector('.video-card__content > a')?.getAttribute('href') || video.querySelector('.content > .img > a')?.getAttribute('href') if (href) { const bvid = matchBvid(href) if (bvid) { - return videoInfoMap.get(bvid)?.isVertical || false + const d = videoInfoMap.get(bvid)?.dimension + return typeof d === 'boolean' ? d : null } } - return false + return null }, } // 检测视频列表 @@ -190,6 +194,12 @@ if (isPagePopular()) { 'global-duration-filter-value', checkVideoList, ) + const popularQualityAction = new QualityAction( + 'popular-quality-filter-status', + 'global-quality-filter-value', + checkVideoList, + ) + const popularDimensionAction = new DimensionAction('popular-dimension-filter-status', checkVideoList) const popularUploaderAction = new UploaderAction( 'popular-uploader-filter-status', 'global-uploader-filter-value', @@ -224,28 +234,40 @@ if (isPagePopular()) { if ( popularDurationAction.status || popularUploaderAction.status || + popularQualityAction.status || + popularDimensionAction.status || popularUploaderKeywordAction.status || popularBvidAction.status || popularTitleKeywordAction.status ) { // 初次全站检测 - popularDurationAction.status && - location.pathname.match(/\/v\/popular\/(?:all|rank|weekly)/) && - (await parseResp()) + if (location.pathname.match(/\/v\/popular\/(?:all|rank|weekly)/)) { + if (popularDurationAction.status || popularQualityAction.status || popularDimensionAction) { + await parseResp() + } else { + parseResp() + } + } checkVideoList(true) } const videoObverser = new MutationObserver(async () => { if ( popularDurationAction.status || popularUploaderAction.status || + popularQualityAction.status || + popularDimensionAction.status || popularUploaderKeywordAction.status || popularBvidAction.status || popularTitleKeywordAction.status ) { // 全量检测 - popularDurationAction.status && - location.pathname.match(/\/v\/popular\/(?:all|rank|weekly)/) && - (await parseResp()) + if (location.pathname.match(/\/v\/popular\/(?:all|rank|weekly)/)) { + if (popularDurationAction.status || popularQualityAction.status || popularDimensionAction) { + await parseResp() + } else { + parseResp() + } + } checkVideoList(true) } }) @@ -341,7 +363,7 @@ if (isPagePopular()) { const durationItems = [ new CheckboxItem({ itemID: popularDurationAction.statusKey, - description: '启用 时长过滤 (实验性)', + description: '启用 时长过滤 (刷新)', itemFunc: () => { popularDurationAction.enable() }, @@ -362,8 +384,45 @@ if (isPagePopular()) { }, }), ] + popularPageVideoFilterGroupList.push(new Group('popular-duration-filter-group', '热门页 时长过滤', durationItems)) + + // UI组件,视频质量过滤part + const qualityItems = [ + new CheckboxItem({ + itemID: popularDimensionAction.statusKey, + description: '启用 竖屏视频过滤 (刷新)', + itemFunc: () => { + popularDimensionAction.enable() + }, + callback: () => { + popularDimensionAction.disable() + }, + }), + new CheckboxItem({ + itemID: popularQualityAction.statusKey, + description: '启用 劣质视频过滤 (刷新)', + itemFunc: () => { + popularQualityAction.enable() + }, + callback: () => { + popularQualityAction.disable() + }, + }), + new NumberItem({ + itemID: popularQualityAction.valueKey, + description: '设定 劣质视频过滤百分比', + defaultValue: 20, + minValue: 0, + maxValue: 80, + disableValue: 0, + unit: '%', + callback: (value: number) => { + popularQualityAction.change(value) + }, + }), + ] popularPageVideoFilterGroupList.push( - new Group('popular-duration-filter-group', '热门页 时长过滤 (刷新生效)', durationItems), + new Group('popular-quality-filter-group', '热门页 视频质量过滤 (实验功能)', qualityItems), ) // UI组件, UP主过滤part