為極致體驗而生的內容過濾器。引入靜態CSS過濾器大幅提升效能,並分離部分規則以提高可維護性。
// ==UserScript==
// @name YouTube 淨化大師
// @namespace http://tampermonkey.net/
// @version 1.2.2
// @description 為極致體驗而生的內容過濾器。引入靜態CSS過濾器大幅提升效能,並分離部分規則以提高可維護性。
// @author Benny, AI Collaborators & The Final Optimizer
// @match https://www.youtube.com/*
// @grant GM_info
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @run-at document-start
// @license MIT
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// ==/UserScript==
(function () {
'use strict';
// --- 1. 設定與常數 ---
const SCRIPT_INFO = GM_info?.script || { name: 'YouTube 淨化大師', version: '1.2.2' };
const ATTRS = {
PROCESSED: 'data-yt-purifier-processed',
HIDDEN_REASON: 'data-yt-purifier-hidden-reason',
WAIT_COUNT: 'data-yt-purifier-wait-count',
};
const State = { HIDE: 'HIDE', KEEP: 'KEEP', WAIT: 'WAIT' };
const DEFAULT_RULE_ENABLES = {
ad_sponsor: true,
members_only: true,
shorts_item: true,
mix_only: true,
premium_banner: true,
news_block: true,
shorts_block: true,
posts_block: true,
shorts_grid_shelf: true,
movies_shelf: true,
youtube_featured_shelf: true,
popular_gaming_shelf: true,
more_from_game_shelf: true,
trending_playlist: true,
inline_survey: true,
clarify_box: true,
explore_topics: true,
};
const DEFAULT_CONFIG = {
LOW_VIEW_THRESHOLD: 1000,
ENABLE_LOW_VIEW_FILTER: true,
DEBUG_MODE: false,
OPEN_IN_NEW_TAB: true, // 預設開啟強制新分頁
ENABLE_KEYWORD_FILTER: false, // 預設關閉關鍵字過濾
KEYWORD_BLACKLIST: [], // 預設空的關鍵字黑名單
ENABLE_CHANNEL_FILTER: false, // 預設關閉頻道過濾
CHANNEL_BLACKLIST: [], // 預設空的頻道黑名單
ENABLE_DURATION_FILTER: false, // 預設關閉長度過濾
DURATION_MIN: 0, // 最短影片長度(秒),0為不限制
DURATION_MAX: 0, // 最長影片長度(秒),0為不限制
GRACE_PERIOD_HOURS: 4, // 新影片豁免期(小時)
};
const CONFIG = {
ENABLE_LOW_VIEW_FILTER: GM_getValue('enableLowViewFilter', DEFAULT_CONFIG.ENABLE_LOW_VIEW_FILTER),
LOW_VIEW_THRESHOLD: GM_getValue('lowViewThreshold', DEFAULT_CONFIG.LOW_VIEW_THRESHOLD),
DEBUG_MODE: GM_getValue('debugMode', DEFAULT_CONFIG.DEBUG_MODE),
OPEN_IN_NEW_TAB: GM_getValue('openInNewTab', DEFAULT_CONFIG.OPEN_IN_NEW_TAB),
RULE_ENABLES: GM_getValue('ruleEnables', { ...DEFAULT_RULE_ENABLES }),
ENABLE_KEYWORD_FILTER: GM_getValue('enableKeywordFilter', DEFAULT_CONFIG.ENABLE_KEYWORD_FILTER),
KEYWORD_BLACKLIST: GM_getValue('keywordBlacklist', [ ...DEFAULT_CONFIG.KEYWORD_BLACKLIST ]),
ENABLE_CHANNEL_FILTER: GM_getValue('enableChannelFilter', DEFAULT_CONFIG.ENABLE_CHANNEL_FILTER),
CHANNEL_BLACKLIST: GM_getValue('channelBlacklist', [ ...DEFAULT_CONFIG.CHANNEL_BLACKLIST ]),
ENABLE_DURATION_FILTER: GM_getValue('enableDurationFilter', DEFAULT_CONFIG.ENABLE_DURATION_FILTER),
DURATION_MIN: GM_getValue('durationMin', DEFAULT_CONFIG.DURATION_MIN),
DURATION_MAX: GM_getValue('durationMax', DEFAULT_CONFIG.DURATION_MAX),
GRACE_PERIOD_HOURS: GM_getValue('gracePeriodHours', DEFAULT_CONFIG.GRACE_PERIOD_HOURS),
DEBOUNCE_DELAY: 50,
WAIT_MAX_RETRY: 5,
};
// --- 2. 選擇器定義 ---
const SELECTORS = {
TOP_LEVEL_FILTERS: [
'ytd-rich-item-renderer', 'ytd-rich-section-renderer', 'ytd-rich-shelf-renderer',
'ytd-video-renderer', 'ytd-compact-video-renderer', 'ytd-reel-shelf-renderer',
'ytd-ad-slot-renderer', 'yt-lockup-view-model', 'ytd-statement-banner-renderer',
'grid-shelf-view-model', 'ytd-playlist-renderer', 'ytd-compact-playlist-renderer',
'ytd-grid-video-renderer', 'ytd-info-panel-container-renderer'
],
CLICKABLE_CONTAINERS: [
'ytd-rich-item-renderer', 'ytd-video-renderer', 'ytd-compact-video-renderer',
'yt-lockup-view-model', 'ytd-playlist-renderer', 'ytd-compact-playlist-renderer',
'ytd-video-owner-renderer', 'ytd-grid-video-renderer'
],
INLINE_PREVIEW_PLAYER: 'ytd-video-preview',
TITLE_TEXT: '#title, #title-text, h2, .yt-shelf-header-layout__title',
init() {
this.COMBINED_SELECTOR = this.TOP_LEVEL_FILTERS.map(s => `${s}:not([${ATTRS.PROCESSED}])`).join(',');
return this;
}
}.init();
// --- 3. 工具函數 ---
const utils = {
debounce: (func, delay) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => func(...a), delay); }; },
injectCSS: () => {
// This is now handled by the much more powerful StaticCSSManager
if (typeof GM_addStyle !== 'function') {
const style = document.createElement('style');
style.type = 'text/css';
style.id = 'yt-purifier-fallback-style';
(document.head || document.documentElement).appendChild(style);
}
},
unitMultiplier: (u) => {
if (!u) return 1;
const m = { 'k': 1e3, 'm': 1e6, 'b': 1e9, '千': 1e3, '萬': 1e4, '万': 1e4, '億': 1e8, '亿': 1e8 };
return m[u.toLowerCase()] || 1;
},
parseNumeric: (text, type) => {
if (!text) return null;
const keywords = {
live: /(正在觀看|觀眾|watching|viewers)/i,
view: /(view|觀看|次)/i,
};
const antiKeywords = /(分鐘|小時|天|週|月|年|ago|minute|hour|day|week|month|year)/i;
const raw = text.replace(/,/g, '').toLowerCase().trim();
if (!keywords[type].test(raw)) return null;
if (type === 'view' && antiKeywords.test(raw) && !keywords.view.test(raw)) return null;
const m = raw.match(/([\d.]+)\s*([kmb千萬万億亿])?/i);
if (!m) return null;
const num = parseFloat(m[1]);
if (isNaN(num)) return null;
return Math.floor(num * utils.unitMultiplier(m[2]));
},
parseLiveViewers: (text) => utils.parseNumeric(text, 'live'),
parseViewCount: (text) => utils.parseNumeric(text, 'view'),
parseDuration: (text) => {
if (!text) return null;
const parts = text.trim().split(':').map(Number);
if (parts.some(isNaN)) return null;
let seconds = 0;
if (parts.length === 3) { // HH:MM:SS
seconds = parts[0] * 3600 + parts[1] * 60 + parts[2];
} else if (parts.length === 2) { // MM:SS
seconds = parts[0] * 60 + parts[1];
} else {
return null;
}
return seconds;
},
parseTimeAgo: (text) => {
if (!text) return null;
const raw = text.toLowerCase();
if (raw.includes('second') || raw.includes('秒')) return 0.1; // 視為極短時間
const numMatch = raw.match(/([\d.]+)/);
if (!numMatch) return null;
const num = parseFloat(numMatch[1]);
if (isNaN(num)) return null;
if (raw.includes('minute') || raw.includes('分鐘')) return num;
if (raw.includes('hour') || raw.includes('小時')) return num * 60;
if (raw.includes('day') || raw.includes('天')) return num * 60 * 24;
if (raw.includes('week') || raw.includes('週')) return num * 60 * 24 * 7;
if (raw.includes('month') || raw.includes('月')) return num * 60 * 24 * 30; // Approximation
if (raw.includes('year') || raw.includes('年')) return num * 60 * 24 * 365; // Approximation
return null;
},
extractAriaTextForCounts(container) {
const a1 = container.querySelector(':scope a#video-title-link[aria-label]');
if (a1?.ariaLabel) return a1.ariaLabel;
const a2 = container.querySelector(':scope a#thumbnail[aria-label]');
if (a2?.ariaLabel) return a2.ariaLabel;
return '';
},
findPrimaryLink(container) {
if (!container) return null;
const candidates = [
'a#thumbnail[href*="/watch?"]', 'a#thumbnail[href*="/shorts/"]', 'a#thumbnail[href*="/playlist?"]',
'a#video-title-link', 'a#video-title', 'a.yt-simple-endpoint#video-title', 'a.yt-lockup-view-model-wiz__title'
];
for (const sel of candidates) {
const a = container.querySelector(sel);
if (a?.href) return a;
}
return container.querySelector('a[href*="/watch?"], a[href*="/shorts/"], a[href*="/playlist?"]');
}
};
// --- 4. 日誌系統 ---
const logger = {
_batch: [],
prefix: `[${SCRIPT_INFO.name}]`,
style: (color) => `color:${color}; font-weight:bold;`,
info: (msg, color = '#3498db') => CONFIG.DEBUG_MODE && console.log(`%c${logger.prefix} [INFO] ${msg}`, logger.style(color)),
startBatch() { if(CONFIG.DEBUG_MODE) this._batch = []; },
hide(source, ruleName, reason, element) {
if (!CONFIG.DEBUG_MODE) return;
this._batch.push({ ruleName, reason, element, source });
},
flushBatch() {
if (!CONFIG.DEBUG_MODE || this._batch.length === 0) return;
const summary = this._batch.reduce((acc, item) => {
acc[item.ruleName] = (acc[item.ruleName] || 0) + 1;
return acc;
}, {});
const summaryString = Object.entries(summary).map(([name, count]) => `${name}: ${count}`).join(', ');
console.groupCollapsed(`%c${this.prefix} [HIDE BATCH] Hiding ${this._batch.length} items from ${this._batch[0].source} | ${summaryString}`, this.style('#e74c3c'));
this._batch.forEach(item => console.log(`Rule:"${item.ruleName}" | Reason:${item.reason}`, item.element));
console.groupEnd();
},
logStart: () => console.log(`%c🚀 ${SCRIPT_INFO.name} v${SCRIPT_INFO.version} 啟動. (Debug: ${CONFIG.DEBUG_MODE})`, 'color:#3498db; font-weight:bold; font-size: 1.2em;'),
};
// --- 5. 靜態 CSS 過濾器 (效能核心) ---
const StaticCSSManager = {
generateAndInject() {
const videoItemContainers = [
'ytd-rich-item-renderer',
'ytd-video-renderer',
'ytd-compact-video-renderer',
'ytd-grid-video-renderer',
'yt-lockup-view-model',
];
const staticRules = [
// --- Direct element hiding ---
{ configKey: 'ad_sponsor', selector: 'ytd-ad-slot-renderer, ytd-promoted-sparkles-text-search-renderer' },
{ configKey: 'premium_banner', selector: 'ytd-statement-banner-renderer' },
{ configKey: 'inline_survey', selector: 'ytd-rich-section-renderer:has(ytd-inline-survey-renderer)' },
{ configKey: 'clarify_box', selector: 'ytd-info-panel-container-renderer' },
// --- Hiding containers using :has() ---
// These apply to individual video/playlist items
{ configKey: 'ad_sponsor', containerSelectors: videoItemContainers, innerSelector: '[aria-label*="廣告"], [aria-label*="Sponsor"], [aria-label="贊助商廣告"]' },
{ configKey: 'members_only', containerSelectors: videoItemContainers, innerSelector: '[aria-label*="會員專屬"]' },
{ configKey: 'shorts_item', containerSelectors: videoItemContainers, innerSelector: 'a[href*="/shorts/"]' },
{ configKey: 'mix_only', containerSelectors: videoItemContainers, innerSelector: 'a[aria-label*="合輯"], a[aria-label*="Mix"]' },
];
let cssString = '';
staticRules.forEach(rule => {
if (CONFIG.RULE_ENABLES[rule.configKey] === false) return;
if (rule.selector) {
cssString += `${rule.selector} { display: none !important; }\n`;
} else if (rule.containerSelectors && rule.innerSelector) {
cssString += rule.containerSelectors.map(container => `${container}:has(${rule.innerSelector})`).join(',\n') + ` { display: none !important; }\n`;
}
});
if (CONFIG.DEBUG_MODE) {
logger.info('Generated Static CSS Rules:', '#2ecc71');
console.log(cssString);
}
if(cssString) GM_addStyle(cssString);
}
};
// --- 6. 功能增強模組 (點擊優化) ---
const Enhancer = {
initGlobalClickListener() {
document.addEventListener('click', (e) => {
if (!CONFIG.OPEN_IN_NEW_TAB) return;
if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
const exclusions = 'button, yt-icon-button, #menu, ytd-menu-renderer, ytd-toggle-button-renderer, yt-chip-cloud-chip-renderer, .yt-spec-button-shape-next, .yt-core-attributed-string__link, #subscribe-button, .ytp-progress-bar, .ytp-chrome-bottom';
if (e.target.closest(exclusions)) return;
let targetLink = null;
const previewPlayer = e.target.closest(SELECTORS.INLINE_PREVIEW_PLAYER);
if (previewPlayer) {
targetLink = utils.findPrimaryLink(previewPlayer) || utils.findPrimaryLink(previewPlayer.closest(SELECTORS.CLICKABLE_CONTAINERS.join(',')));
} else {
const container = e.target.closest(SELECTORS.CLICKABLE_CONTAINERS.join(', '));
if (!container) return;
const channelLink = e.target.closest('a#avatar-link, .ytd-channel-name a, a[href^="/@"], a[href^="/channel/"]');
targetLink = channelLink?.href ? channelLink : utils.findPrimaryLink(container);
}
if (!targetLink) return;
try {
const hostname = new URL(targetLink.href, location.origin).hostname;
const isValidTarget = targetLink.href && /(^|\.)youtube\.com$/.test(hostname);
if (isValidTarget) {
e.preventDefault();
e.stopImmediatePropagation();
window.open(targetLink.href, '_blank');
}
} catch (err) {}
}, { capture: true });
}
};
// --- 7. 核心規則引擎 (動態) ---
const RuleEngine = {
ruleCache: new Map(),
globalRules: [],
_elementDataCache: new WeakMap(),
init() {
this.ruleCache.clear();
this.globalRules = [];
this._elementDataCache = new WeakMap();
const activeRules = this._buildBaseRules().filter(rule => CONFIG.RULE_ENABLES[rule.id] !== false);
this._addConditionalRules(activeRules);
this._populateRuleCaches(activeRules);
},
_buildBaseRules() {
return [
// 'ad_sponsor' is now 100% in StaticCSSManager
// 'shorts_item' is now 100% in StaticCSSManager
// 'premium_banner' is now 100% in StaticCSSManager
// 'inline_survey' is now 100% in StaticCSSManager
// 'clarify_box' is now 100% in StaticCSSManager
// Kept text-based parts of mixed rules
{ id: 'members_only', name: '會員專屬', conditions: { any: [ { type: 'text', selector: '.badge-shape-wiz__text, .yt-badge-shape__text', keyword: /頻道會員專屬|Members only/i } ] } },
{ id: 'mix_only', name: '合輯 (Mix)', conditions: { any: [ { type: 'text', selector: '.badge-shape-wiz__text, ytd-thumbnail-overlay-side-panel-renderer, .yt-badge-shape__text', keyword: /(^|\s)(合輯|Mix)(\s|$)/i }, { type: 'text', selector: '#video-title, .yt-lockup-metadata-view-model__title', keyword: /^(合輯|Mix)[\s-–]/i } ] } },
// Kept all rules that rely on text matching for shelf/section titles
{ id: 'news_block', name: '新聞區塊', scope: 'ytd-rich-shelf-renderer, ytd-rich-section-renderer', conditions: { any: [{ type: 'text', selector: SELECTORS.TITLE_TEXT, keyword: /新聞快報|Breaking News|ニュース/i }] } },
{ id: 'shorts_block', name: 'Shorts 區塊', scope: 'ytd-rich-shelf-renderer, ytd-reel-shelf-renderer, ytd-rich-section-renderer', conditions: { any: [{ type: 'text', selector: SELECTORS.TITLE_TEXT, keyword: /^Shorts$/i }] } },
{ id: 'posts_block', name: '貼文區塊', scope: 'ytd-rich-shelf-renderer, ytd-rich-section-renderer', conditions: { any: [{ type: 'text', selector: SELECTORS.TITLE_TEXT, keyword: /貼文|Posts|投稿|Publicaciones|最新 YouTube 貼文/i }] } },
{ id: 'explore_topics', name: '探索更多主題', scope: 'ytd-rich-section-renderer', conditions: { any: [{ type: 'text', selector: SELECTORS.TITLE_TEXT, keyword: /探索更多主題|Explore more topics/i }] } },
{ id: 'shorts_grid_shelf', name: 'Shorts 區塊 (Grid)', scope: 'grid-shelf-view-model', conditions: { any: [{ type: 'text', selector: 'h2.shelf-header-layout-wiz__title', keyword: /^Shorts$/i }] } },
{ id: 'movies_shelf', name: '電影推薦區塊', scope: 'ytd-rich-shelf-renderer, ytd-rich-section-renderer', conditions: { any: [ { type: 'text', selector: SELECTORS.TITLE_TEXT, keyword: /為你推薦的特選電影|featured movies/i }, { type: 'text', selector: 'p.ytd-badge-supported-renderer', keyword: /YouTube 精選/i } ] } },
{ id: 'youtube_featured_shelf', name: 'YouTube 精選區塊', scope: 'ytd-rich-shelf-renderer, ytd-rich-section-renderer', conditions: { any: [ { type: 'text', selector: '.yt-shelf-header-layout__sublabel', keyword: /YouTube 精選/i } ] } },
{ id: 'popular_gaming_shelf', name: '熱門遊戲區塊', scope: 'ytd-rich-shelf-renderer, ytd-rich-section-renderer', conditions: { any: [{ type: 'text', selector: SELECTORS.TITLE_TEXT, keyword: /^熱門遊戲直播$/i }] } },
{ id: 'more_from_game_shelf', name: '「更多相關內容」區塊', scope: 'ytd-rich-shelf-renderer, ytd-rich-section-renderer', conditions: { any: [{ type: 'text', selector: '#subtitle', keyword: /^更多此遊戲相關內容$/i }] } },
{ id: 'trending_playlist', name: '發燒影片/熱門內容', scope: 'ytd-rich-item-renderer, yt-lockup-view-model', conditions: { any: [{ type: 'text', selector: 'h3 a, #video-title', keyword: /發燒影片|Trending/i }] } },
];
},
_addConditionalRules(activeRules) {
const videoScope = 'ytd-rich-item-renderer, ytd-video-renderer, ytd-compact-video-renderer, yt-lockup-view-model, ytd-grid-video-renderer';
if (CONFIG.ENABLE_LOW_VIEW_FILTER) {
activeRules.push(
{ id: 'low_viewer_live', name: '低觀眾直播', scope: videoScope, conditions: { any: [{ type: 'liveViewers', threshold: CONFIG.LOW_VIEW_THRESHOLD }] } },
{ id: 'low_view_video', name: '低觀看影片', scope: videoScope, conditions: { any: [{ type: 'viewCount', threshold: CONFIG.LOW_VIEW_THRESHOLD }] } }
);
}
if (CONFIG.ENABLE_KEYWORD_FILTER && CONFIG.KEYWORD_BLACKLIST.length > 0) {
activeRules.push({ id: 'keyword_blacklist', name: '關鍵字過濾', scope: videoScope, conditions: { any: [{ type: 'titleKeyword', keywords: CONFIG.KEYWORD_BLACKLIST }] } });
}
if (CONFIG.ENABLE_CHANNEL_FILTER && CONFIG.CHANNEL_BLACKLIST.length > 0) {
activeRules.push({ id: 'channel_blacklist', name: '頻道過濾', scope: videoScope, conditions: { any: [{ type: 'channelName', channels: CONFIG.CHANNEL_BLACKLIST }] } });
}
if (CONFIG.ENABLE_DURATION_FILTER && (CONFIG.DURATION_MIN > 0 || CONFIG.DURATION_MAX > 0)) {
activeRules.push({ id: 'duration_filter', name: '影片長度過濾', scope: videoScope, conditions: { any: [{ type: 'duration', min: CONFIG.DURATION_MIN, max: CONFIG.DURATION_MAX }] } });
}
},
_populateRuleCaches(rulesToPopulate) {
rulesToPopulate.forEach(rule => {
const scopes = rule.scope ? rule.scope.split(',') : [null];
scopes.forEach(scope => {
const target = scope ? scope.trim().toUpperCase() : 'GLOBAL';
if (target === 'GLOBAL') {
this.globalRules.push(rule);
} else {
if (!this.ruleCache.has(target)) this.ruleCache.set(target, []);
this.ruleCache.get(target).push(rule);
}
});
});
},
checkCondition(container, condition) {
const cachedData = this._getElementData(container);
try {
switch (condition.type) {
case 'selector': return container.querySelector(`:scope ${condition.value}`) ? { state: State.HIDE, reason: `Selector: ${condition.value}` } : { state: State.KEEP };
case 'text':
for (const el of container.querySelectorAll(`:scope ${condition.selector}`)) {
if (condition.keyword.test(el.textContent)) return { state: State.HIDE, reason: `Text: "${el.textContent.trim()}"` };
}
return { state: State.KEEP };
case 'titleKeyword':
if (!cachedData.title) return { state: State.KEEP };
return condition.keywords.some(keyword => keyword && cachedData.title.includes(keyword.toLowerCase())) ? { state: State.HIDE, reason: `Keyword: "${condition.keywords.find(kw => cachedData.title.includes(kw.toLowerCase()))}"` } : { state: State.KEEP };
case 'channelName':
if (!cachedData.channelName) return { state: State.KEEP };
return condition.channels.some(blocked => blocked && cachedData.channelName === blocked.toLowerCase()) ? { state: State.HIDE, reason: `Channel: "${condition.channels.find(cn => cachedData.channelName === cn.toLowerCase())}"` } : { state: State.KEEP };
case 'duration': {
if (cachedData.durationInSeconds === null) return cachedData.isShorts ? { state: State.KEEP } : { state: State.WAIT };
if (condition.min > 0 && cachedData.durationInSeconds < condition.min) return { state: State.HIDE, reason: `Duration ${cachedData.durationInSeconds}s < min ${condition.min}s` };
if (condition.max > 0 && cachedData.durationInSeconds > condition.max) return { state: State.HIDE, reason: `Duration ${cachedData.durationInSeconds}s > max ${condition.max}s` };
return { state: State.KEEP };
}
case 'liveViewers': case 'viewCount': {
// 新影片豁免期邏輯
if (cachedData.timeAgoInMinutes !== null && cachedData.timeAgoInMinutes < (CONFIG.GRACE_PERIOD_HOURS * 60)) {
if (CONFIG.DEBUG_MODE) {
console.log(`[Grace Period] Keeping video "${cachedData.title}" (${cachedData.timeAgoInMinutes} mins old)`);
}
return { state: State.KEEP };
}
const count = condition.type === 'liveViewers' ? cachedData.liveViewers : cachedData.viewCount;
if (count === null) return container.tagName.includes('PLAYLIST') ? { state: State.KEEP } : { state: State.WAIT };
return count < condition.threshold ? { state: State.HIDE, reason: `${condition.type}: ${count} < ${condition.threshold}` } : { state: State.KEEP };
}
default: return { state: State.KEEP };
}
} catch (e) { return { state: State.KEEP }; }
},
checkRule(container, rule) {
if (rule.scope && !container.matches(rule.scope)) return { state: State.KEEP };
let requiresWait = false;
for (const condition of rule.conditions.any) {
const result = this.checkCondition(container, condition);
if (result.state === State.HIDE) return { ...result, ruleId: rule.id };
if (result.state === State.WAIT) requiresWait = true;
}
return requiresWait ? { state: State.WAIT } : { state: State.KEEP };
},
_getElementData(container) {
if (this._elementDataCache.has(container)) return this._elementDataCache.get(container);
const data = {};
data.title = container.querySelector('#video-title')?.textContent?.toLowerCase() || '';
data.channelName = container.querySelector('ytd-channel-name .yt-formatted-string, .ytd-channel-name a')?.textContent?.trim()?.toLowerCase() || '';
const durationEl = container.querySelector('ytd-thumbnail-overlay-time-status-renderer, span.ytd-thumbnail-overlay-time-status-renderer');
data.durationInSeconds = utils.parseDuration(durationEl?.textContent);
const metadataTexts = [ ...Array.from(container.querySelectorAll('#metadata-line .inline-metadata-item, #metadata-line span.ytd-grid-video-renderer, .yt-content-metadata-view-model-wiz__metadata-text, .yt-content-metadata-view-model__metadata-text'), el => el.textContent), utils.extractAriaTextForCounts(container) ];
data.liveViewers = null;
data.viewCount = null;
data.timeAgoInMinutes = null;
for (const text of metadataTexts) {
if (data.liveViewers === null) data.liveViewers = utils.parseLiveViewers(text);
if (data.viewCount === null) data.viewCount = utils.parseViewCount(text);
if (data.timeAgoInMinutes === null) data.timeAgoInMinutes = utils.parseTimeAgo(text);
}
data.isShorts = container.querySelector('a[href*="/shorts/"]') !== null;
this._elementDataCache.set(container, data);
return data;
},
processContainer(container, source) {
if (container.hasAttribute(ATTRS.PROCESSED)) return;
const relevantRules = (this.ruleCache.get(container.tagName) || []).concat(this.globalRules);
let finalState = State.KEEP;
for (const rule of relevantRules) {
const result = this.checkRule(container, rule);
if (result.state === State.HIDE) {
let finalTarget = container.closest('ytd-rich-item-renderer, ytd-video-renderer, ytd-compact-video-renderer, ytd-grid-video-renderer') || container;
finalTarget.style.setProperty('display', 'none', 'important');
finalTarget.setAttribute(ATTRS.PROCESSED, 'hidden');
finalTarget.setAttribute(ATTRS.HIDDEN_REASON, result.ruleId);
logger.hide(source, rule.name, result.reason, finalTarget);
return;
}
if (result.state === State.WAIT) finalState = State.WAIT;
}
if (finalState === State.WAIT) {
const count = +(container.getAttribute(ATTRS.WAIT_COUNT) || 0) + 1;
if (count >= CONFIG.WAIT_MAX_RETRY) container.setAttribute(ATTRS.PROCESSED, 'checked-wait-expired');
else container.setAttribute(ATTRS.WAIT_COUNT, String(count));
} else {
container.setAttribute(ATTRS.PROCESSED, 'checked');
}
}
};
// --- 8. 主控台與菜單系統 ---
const Main = {
menuHandle: null,
menuStructure: null,
scanPage: (source) => {
logger.startBatch();
document.querySelectorAll(SELECTORS.COMBINED_SELECTOR).forEach(el => RuleEngine.processContainer(el, source));
logger.flushBatch();
},
resetAndRescan(message) {
logger.info(message);
document.querySelectorAll(`[${ATTRS.PROCESSED}]`).forEach(el => {
el.style.display = '';
el.removeAttribute(ATTRS.PROCESSED);
el.removeAttribute(ATTRS.HIDDEN_REASON);
el.removeAttribute(ATTRS.WAIT_COUNT);
});
// Re-inject CSS in case rules were toggled
StaticCSSManager.generateAndInject();
RuleEngine.init();
Main.scanPage('settings-changed');
},
_buildMenu() {
this.menuStructure = {
title: '【 YouTube 淨化大師 - 設定 】',
items: {
'1': { title: '📂 設定詳細過濾規則', type: 'submenu', getItems: () => this._buildRuleSubmenu() },
'2': { title: '啟用「低觀看數過濾」', type: 'toggle', config: 'ENABLE_LOW_VIEW_FILTER', afterAction: () => this.resetAndRescan() },
'3': { title: () => `🔢 修改過濾閾值 (目前: ${CONFIG.LOW_VIEW_THRESHOLD})`, type: 'number', config: 'LOW_VIEW_THRESHOLD', promptText: '請輸入新的過濾閾值', afterAction: () => this.resetAndRescan() },
'4': { title: '🚫 進階過濾設定', type: 'submenu', items: this._buildAdvancedSubmenu() },
'5': { title: '強制新分頁開啟影片', type: 'toggle', config: 'OPEN_IN_NEW_TAB' },
'6': { title: 'Debug 模式', type: 'toggle', config: 'DEBUG_MODE', afterAction: () => this.resetAndRescan() },
'7': { title: '🔄 恢復預設設定', type: 'action', action: () => { if (confirm('⚠️ 確定要恢復預設值嗎?')) this._resetAllToDefaults(); } }
}
};
},
_buildRuleSubmenu() {
// We need to get all original rules for the menu, even those in CSS
const allBaseRules = [
{ id: 'ad_sponsor', name: '廣告/促銷' },
{ id: 'members_only', name: '會員專屬' },
{ id: 'shorts_item', name: 'Shorts (單個)'},
{ id: 'mix_only', name: '合輯 (Mix)' },
{ id: 'premium_banner', name: 'Premium 推廣' },
{ id: 'news_block', name: '新聞區塊' },
{ id: 'shorts_block', name: 'Shorts 區塊' },
{ id: 'posts_block', name: '貼文區塊' },
{ id: 'explore_topics', name: '探索更多主題' },
{ id: 'shorts_grid_shelf', name: 'Shorts 區塊 (Grid)' },
{ id: 'movies_shelf', name: '電影推薦區塊' },
{ id: 'youtube_featured_shelf', name: 'YouTube 精選區塊' },
{ id: 'popular_gaming_shelf', name: '熱門遊戲區塊' },
{ id: 'more_from_game_shelf', name: '「更多相關內容」區塊' },
{ id: 'trending_playlist', name: '發燒影片/熱門內容' },
{ id: 'inline_survey', name: '意見調查問卷' },
{ id: 'clarify_box', name: '資訊面板 (Wiki)' },
];
const items = allBaseRules.reduce((acc, rule, index) => {
acc[index + 1] = { title: rule.name, type: 'toggle', config: `RULE_ENABLES.${rule.id}`, afterAction: () => this.resetAndRescan() };
return acc;
}, {});
items['0'] = { title: '⬅️ 返回主選單', type: 'back' };
return items;
},
_buildAdvancedSubmenu() {
return {
'1': { title: '啟用「關鍵字過濾」', type: 'toggle', config: 'ENABLE_KEYWORD_FILTER', afterAction: () => this.resetAndRescan() },
'2': { title: '📖 管理關鍵字黑名單', type: 'action', action: () => this._manageList('KEYWORD_BLACKLIST', '關鍵字') },
'3': { title: '啟用「頻道過濾」', type: 'toggle', config: 'ENABLE_CHANNEL_FILTER', afterAction: () => this.resetAndRescan() },
'4': { title: '👤 管理頻道黑名單', type: 'action', action: () => this._manageList('CHANNEL_BLACKLIST', '頻道') },
'5': { title: '啟用「影片長度過濾」', type: 'toggle', config: 'ENABLE_DURATION_FILTER', afterAction: () => this.resetAndRescan() },
'6': { title: '⏱️ 管理影片長度', type: 'action', action: () => this._manageDuration() },
'7': {
title: () => `🛡️ 設定新影片豁免期 (目前: ${CONFIG.GRACE_PERIOD_HOURS} 小時)`,
type: 'number',
config: 'GRACE_PERIOD_HOURS',
promptText: '請輸入新影片豁免期 (小時)\n在此時間內發布的影片將不受觀看數限制:',
afterAction: () => this.resetAndRescan()
},
'0': { title: '⬅️ 返回主選單', type: 'back' }
};
},
_renderMenu(menuNode) {
let text = `${menuNode.title}\n\n`;
const items = typeof menuNode.getItems === 'function' ? menuNode.getItems() : menuNode.items;
const s = (val) => val ? '✅' : '❌';
const separator = '--------------------------\n';
Object.keys(items).forEach(key => {
if (menuNode === this.menuStructure && ['2', '5', '7'].includes(key)) text += separator;
if (key === '0' && menuNode !== this.menuStructure) text += separator;
const item = items[key];
let title = typeof item.title === 'function' ? item.title() : item.title;
if (item.type === 'toggle') {
const keys = item.config.split('.');
const value = keys.length > 1 ? CONFIG[keys[0]][keys[1]] : CONFIG[keys[0]];
title = `${s(value)} ${title}`;
}
text += `${key}. ${title}\n`;
});
const choice = prompt(text);
if (choice === null) return;
const selected = items[choice.trim()];
if (!selected) { alert('❌ 無效的選項'); return setTimeout(() => this._renderMenu(menuNode), 50); }
let nextMenu = menuNode;
switch (selected.type) {
case 'submenu': selected.parent = menuNode; nextMenu = selected; break;
case 'toggle': {
const keys = selected.config.split('.');
const isNested = keys.length > 1;
const value = isNested ? !CONFIG[keys[0]][keys[1]] : !CONFIG[keys[0]];
if (isNested) {
const ruleSet = { ...CONFIG[keys[0]], [keys[1]]: value };
CONFIG[keys[0]] = ruleSet;
GM_setValue('ruleEnables', ruleSet);
}
else { CONFIG[keys[0]] = value; GM_setValue(selected.config.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`), value); }
if (selected.afterAction) selected.afterAction();
break;
}
case 'number': {
const currentVal = CONFIG[selected.config];
const input = prompt(selected.promptText, currentVal);
if (input !== null) {
const newVal = parseInt(input, 10);
if (!isNaN(newVal) && newVal >= 0) { CONFIG[selected.config] = newVal; GM_setValue(selected.config.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`), newVal); if (selected.afterAction) selected.afterAction(); }
else { alert('❌ 請輸入有效的正整數。'); }
}
break;
}
case 'action': nextMenu = selected.action() || menuNode; break;
case 'back': nextMenu = menuNode.parent || menuNode; break;
}
if (nextMenu) setTimeout(() => this._renderMenu(nextMenu), 50);
},
_manageList(configKey, itemName) {
const list = CONFIG[configKey];
const text = `【管理${itemName}黑名單】\n目前: ${list.length > 0 ? `[ ${list.join(', ')} ]` : '(無)'}\n\n1.新增, 2.刪除, 3.清空, 0.返回`;
const choice = parseInt(prompt(text), 10);
switch (choice) {
case 1: {
const items = prompt(`輸入要新增的${itemName} (用逗號分隔)`);
if (items) {
const toAdd = items.split(',').map(i => i.trim().toLowerCase()).filter(i => i && !list.includes(i));
if (toAdd.length > 0) { list.push(...toAdd); GM_setValue(configKey.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`), list); this.resetAndRescan(); }
}
break;
}
case 2: {
const item = prompt(`輸入要刪除的${itemName}:\n[ ${list.join(', ')} ]`);
if (item) {
const idx = list.findIndex(i => i.toLowerCase() === item.trim().toLowerCase());
if (idx > -1) { list.splice(idx, 1); GM_setValue(configKey.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`), list); this.resetAndRescan(); } else { alert('項目不存在'); }
}
break;
}
case 3: if (confirm(`⚠️ 確定要清空所有${itemName}黑名單嗎?`)) { list.length = 0; GM_setValue(configKey.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`), list); this.resetAndRescan(); } break;
case 0: return this.menuStructure.items['4'];
}
return () => this._manageList(configKey, itemName);
},
_manageDuration() {
const min = CONFIG.DURATION_MIN; const max = CONFIG.DURATION_MAX;
const text = `【管理影片長度過濾】(0=不限制)\n\n1. 最短長度 (分): ${min > 0 ? min/60 : '無'}\n2. 最長長度 (分): ${max > 0 ? max/60 : '無'}\n3. 重設\n0. 返回`;
const choice = parseInt(prompt(text), 10);
const parse = (val) => (val === null || val.trim() === '') ? null : (isNaN(parseFloat(val)) ? null : Math.floor(parseFloat(val) * 60));
switch (choice) {
case 1: { const v = parse(prompt('輸入最短影片長度 (分鐘)', min > 0 ? min/60 : '')); if (v !== null) { CONFIG.DURATION_MIN = v; GM_setValue('duration_min', v); this.resetAndRescan(); } break; }
case 2: { const v = parse(prompt('輸入最長影片長度 (分鐘)', max > 0 ? max/60 : '')); if (v !== null) { CONFIG.DURATION_MAX = v; GM_setValue('duration_max', v); this.resetAndRescan(); } break; }
case 3: if (confirm('⚠️ 確定要重設長度限制嗎?')) { CONFIG.DURATION_MIN = 0; CONFIG.DURATION_MAX = 0; GM_setValue('duration_min', 0); GM_setValue('duration_max', 0); this.resetAndRescan(); } break;
case 0: return this.menuStructure.items['4'];
}
return () => this._manageDuration();
},
_resetAllToDefaults() {
Object.keys(DEFAULT_CONFIG).forEach(key => {
const gmKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
CONFIG[key] = DEFAULT_CONFIG[key];
GM_setValue(gmKey, DEFAULT_CONFIG[key]);
});
CONFIG.RULE_ENABLES = { ...DEFAULT_RULE_ENABLES };
GM_setValue('ruleEnables', CONFIG.RULE_ENABLES);
this.resetAndRescan('系統已恢復預設值');
alert('✅ 所有設定已恢復預設值。');
},
setupMenu() {
if (this.menuHandle) { try { GM_unregisterMenuCommand(this.menuHandle); } catch (e) {} }
this.menuHandle = GM_registerMenuCommand('⚙️ 淨化大師設定 (Settings)...', () => {
this._buildMenu();
this._renderMenu(this.menuStructure);
});
},
init() {
if (window.ytPurifierInitialized) return;
window.ytPurifierInitialized = true;
logger.logStart();
// **PERFORMANCE**: Inject static CSS rules first for immediate filtering
StaticCSSManager.generateAndInject();
RuleEngine.init();
this.setupMenu();
Enhancer.initGlobalClickListener();
const debouncedScan = utils.debounce(() => Main.scanPage('observer'), CONFIG.DEBOUNCE_DELAY);
const observer = new MutationObserver(debouncedScan);
const onReady = () => {
observer.observe(document.body, { childList: true, subtree: true });
window.addEventListener('yt-navigate-finish', () => Main.scanPage('navigate'));
Main.scanPage('initial');
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', onReady, { once: true });
else onReady();
}
};
Main.init();
})();