From 9592dd5bd88fa0a39608f803dbf043c209bae76a Mon Sep 17 00:00:00 2001 From: AbrahamOP Date: Sun, 13 Sep 2026 11:27:18 +0200 Subject: [PATCH] feat(youtube): block YouTube ads (player data pruning + player fallback) Merges the two YouTube approaches from #10 and #11: - youtube-inject.js (MAIN world, document_start): removes playerAds, adPlacements, adSlots and adBreakHeartbeatParams from the player response embedded in the page (ytInitialPlayerResponse, missed so far on a direct /watch load) and from youtubei player/next/reel responses (fetch, incl. Request objects, and XHR text/json). The player never schedules the ad. - It can't read extension storage, so background.js registers it with chrome.scripting only while protection is active and excludes allow-listed domains: pause, off and allow list apply from the first script of the page. Registration is serialized to avoid duplicate ids. - youtube.js (isolated, all frames incl. youtube-nocookie): fallback when an ad still starts. Hides and mutes it, fast-forwards it with a per-source guard and a grace period after a source change so the real video is never sent to its end, clicks skip, restores the sound it muted. Ad slots (masthead, feed, banners) follow the cosmetic setting. - rules: youtube.com/api/stats/ads, /pagead/, /ptracking (other doubleclick hosts are already covered by rule 1). - CI: syntax-check both scripts and the runtime-registered file. Closes #10, closes #11. Co-authored-by: PoPoGH --- .github/workflows/ci.yml | 3 +- background.js | 41 ++++++++++- manifest.json | 14 +++- rules.json | 27 +++++++ youtube-inject.js | 101 ++++++++++++++++++++++++++ youtube.js | 151 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 youtube-inject.js create mode 100644 youtube.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d3a308..58aad6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - name: Check JS syntax run: | - for f in background.js content.js popup.js dashboard.js; do + for f in background.js content.js popup.js dashboard.js youtube.js youtube-inject.js; do node --check "$f" done @@ -36,6 +36,7 @@ jobs: (cs.js || []).forEach(check); (cs.css || []).forEach(check); } + check('youtube-inject.js'); // registered at runtime by background.js for (const r of m.declarative_net_request?.rule_resources || []) check(r.path); for (const size of Object.keys(m.icons || {})) check(m.icons[size]); if (missing.length) { console.error('Missing files:', missing); process.exit(1); } diff --git a/background.js b/background.js index a6a133a..a0b10f4 100644 --- a/background.js +++ b/background.js @@ -113,6 +113,41 @@ async function rebuildAllowRules(whitelist) { return replaceDynamicRulesInRange(rules, DYNAMIC_ALLOW_START, DYNAMIC_ALLOW_START + 9999); } +// ───────────────────────────────────────────── +// YouTube player data pruning (youtube-inject.js) +// Runs in the page's world, where it can't read storage: register it only while +// protection is active, excluding allow-listed domains, so pause/off/allow list +// apply from the very first script of the page. +// ───────────────────────────────────────────── +const YT_INJECT_ID = 'goablockad-youtube-inject'; +let ytSyncQueue = Promise.resolve(); + +// Serialized: concurrent unregister/register pairs would collide on the script id. +function syncYouTubeInjection() { + ytSyncQueue = ytSyncQueue.then(doSyncYouTubeInjection); + return ytSyncQueue; +} + +async function doSyncYouTubeInjection() { + const { whitelist = [] } = await chrome.storage.local.get('whitelist'); + const active = await isProtectionActive(); + try { + await chrome.scripting.unregisterContentScripts({ ids: [YT_INJECT_ID] }).catch(() => {}); + if (!active) return; + await chrome.scripting.registerContentScripts([{ + id: YT_INJECT_ID, + js: ['youtube-inject.js'], + matches: ['*://*.youtube.com/*', '*://*.youtube-nocookie.com/*'], + excludeMatches: whitelist.flatMap(d => [`*://${d}/*`, `*://*.${d}/*`]), + runAt: 'document_start', + world: 'MAIN', + allFrames: true + }]); + } catch (err) { + console.error('GoaBlockAD: YouTube script registration failed', err); + } +} + // ───────────────────────────────────────────── // Pause mode // ───────────────────────────────────────────── @@ -135,6 +170,7 @@ async function applyProtectionState() { } // Custom rules are dynamic and ignore the ruleset toggle: add/remove them too. await rebuildCustomRules(); + await syncYouTubeInjection(); updateBadgeStyle(shouldEnable); } @@ -194,7 +230,10 @@ chrome.runtime.onStartup.addListener(async () => { // ───────────────────────────────────────────── chrome.storage.onChanged.addListener(async (changes, namespace) => { if (namespace !== 'local') return; - if (changes.whitelist) await rebuildAllowRules(changes.whitelist.newValue || []); + if (changes.whitelist) { + await rebuildAllowRules(changes.whitelist.newValue || []); + await syncYouTubeInjection(); + } if (changes.enabled || changes.pausedUntil) { await applyProtectionState(); await refreshBadgeText(); diff --git a/manifest.json b/manifest.json index 710ac07..3ffeec9 100644 --- a/manifest.json +++ b/manifest.json @@ -7,7 +7,8 @@ "declarativeNetRequest", "storage", "alarms", - "activeTab" + "activeTab", + "scripting" ], "host_permissions": [ "" @@ -50,6 +51,17 @@ "content.js" ], "run_at": "document_idle" + }, + { + "matches": [ + "*://*.youtube.com/*", + "*://*.youtube-nocookie.com/*" + ], + "js": [ + "youtube.js" + ], + "run_at": "document_start", + "all_frames": true } ] } \ No newline at end of file diff --git a/rules.json b/rules.json index 8cd65b4..a03ef2a 100644 --- a/rules.json +++ b/rules.json @@ -295,5 +295,32 @@ "urlFilter": "||smartadserver.com^", "resourceTypes": ["script", "image", "xmlhttprequest", "sub_frame"] } + }, + { + "id": 34, + "priority": 1, + "action": { "type": "block" }, + "condition": { + "urlFilter": "||youtube.com/api/stats/ads", + "resourceTypes": ["script", "image", "xmlhttprequest", "sub_frame", "ping", "other"] + } + }, + { + "id": 35, + "priority": 1, + "action": { "type": "block" }, + "condition": { + "urlFilter": "||youtube.com/pagead/", + "resourceTypes": ["script", "image", "xmlhttprequest", "sub_frame", "ping", "other"] + } + }, + { + "id": 36, + "priority": 1, + "action": { "type": "block" }, + "condition": { + "urlFilter": "||youtube.com/ptracking", + "resourceTypes": ["script", "image", "xmlhttprequest", "sub_frame", "ping", "other"] + } } ] diff --git a/youtube-inject.js b/youtube-inject.js new file mode 100644 index 0000000..c0e9e9d --- /dev/null +++ b/youtube-inject.js @@ -0,0 +1,101 @@ +// GoaBlockAD - YouTube player data pruning +// Runs in the page (MAIN world) at document_start, so it sees the player data +// before the player does: ad fields are removed from the initial player response +// embedded in the page and from the youtubei API responses fetched on SPA +// navigation. The player then never schedules an ad. +// Registered dynamically by background.js only while protection is active and +// YouTube isn't allow-listed — this script has no access to extension storage. +// Based on the approach proposed by @PoPoGH in #10. + +(() => { + if (window.__goablockadYt) return; + window.__goablockadYt = true; + + const AD_KEYS = new Set(['playerAds', 'adPlacements', 'adSlots', 'adBreakHeartbeatParams']); + const API_PATHS = ['/youtubei/v1/player', '/youtubei/v1/next', '/youtubei/v1/reel/reel_watch_sequence']; + + function prune(obj) { + if (!obj || typeof obj !== 'object') return obj; + for (const key of Object.keys(obj)) { + if (AD_KEYS.has(key)) delete obj[key]; + else prune(obj[key]); + } + return obj; + } + + function pruneText(text) { + try { + return JSON.stringify(prune(JSON.parse(text))); + } catch (_) { + return text; + } + } + + function isApiUrl(url) { + try { + const { pathname } = new URL(String(url), location.href); + return API_PATHS.some(p => pathname.startsWith(p)); + } catch (_) { + return false; + } + } + + // ── Data embedded in the page (first load of /watch, /shorts) ── + // YouTube assigns `var ytInitialPlayerResponse = {...}` in an inline script; + // an accessor on window catches that assignment. + let initialPlayerResponse; + Object.defineProperty(window, 'ytInitialPlayerResponse', { + configurable: true, + get: () => initialPlayerResponse, + set: (value) => { initialPlayerResponse = prune(value); } + }); + + // ── fetch ── + const origFetch = window.fetch; + window.fetch = function (input, init) { + const promise = origFetch.apply(this, arguments); + const url = input instanceof Request ? input.url : input; + if (!isApiUrl(url)) return promise; + return promise.then(response => { + if (!response.ok) return response; + return response.clone().text().then(text => { + const pruned = new Response(pruneText(text), { + status: response.status, + statusText: response.statusText, + headers: response.headers + }); + Object.defineProperty(pruned, 'url', { value: response.url }); + return pruned; + }, () => response); + }); + }; + + // ── XMLHttpRequest ── + const xhrProto = XMLHttpRequest.prototype; + const origOpen = xhrProto.open; + const responseText = Object.getOwnPropertyDescriptor(xhrProto, 'responseText').get; + const response = Object.getOwnPropertyDescriptor(xhrProto, 'response').get; + + xhrProto.open = function (method, url) { + if (isApiUrl(url)) { + let raw, pruned; + const cached = (value) => { + if (value !== raw) { raw = value; pruned = pruneText(value); } + return pruned; + }; + Object.defineProperty(this, 'responseText', { + configurable: true, + get() { return cached(responseText.call(this)); } + }); + Object.defineProperty(this, 'response', { + configurable: true, + get() { + const value = response.call(this); + if (typeof value === 'string') return cached(value); + return this.responseType === 'json' ? prune(value) : value; + } + }); + } + return origOpen.apply(this, arguments); + }; +})(); diff --git a/youtube.js b/youtube.js new file mode 100644 index 0000000..41eea7b --- /dev/null +++ b/youtube.js @@ -0,0 +1,151 @@ +// GoaBlockAD - YouTube player fallback +// youtube-inject.js removes ads from the player data; this script catches what +// slips through. YouTube serves video ads from googlevideo.com, like the videos +// themselves, so they can't be blocked at the network level: instead the ad is +// hidden, muted and fast-forwarded, and the skip button clicked. Static ad slots +// (masthead, feed, banners) are hidden with CSS. +// Merges #11 and the player handling proposed by @PoPoGH in #10. + +const YT_STYLE_ID = 'goablockad-youtube-style'; +const SKIP_SELECTORS = [ + '.ytp-ad-skip-button', + '.ytp-ad-skip-button-modern', + '.ytp-skip-ad-button', + '.ytp-ad-skip-button-container button', + 'button[class*="ytp-ad-skip"]', + 'button[class*="ytp-skip-ad"]' +]; +// Hides the ad while it's still on screen, before the skip kicks in. +const PLAYER_AD_CSS = ` + .html5-video-player.ad-showing video, + .html5-video-player.ad-interrupting video { + opacity: 0 !important; + } + .ytp-ad-module, + .ytp-ad-player-overlay, + .ytp-ad-player-overlay-layout, + .ytp-ad-image-overlay, + .ytp-ad-overlay-container, + .ytp-ad-text-overlay, + .ytp-ad-action-interstitial, + .ytp-flyout-cta, + .ytp-ad-survey { + display: none !important; + } +`; +// Cosmetic part, follows the "cosmetic" setting like content.js. +const AD_SLOTS_CSS = ` + #masthead-ad, + #player-ads, + ytd-ad-slot-renderer, + ytd-rich-item-renderer:has(ytd-ad-slot-renderer), + ytd-in-feed-ad-layout-renderer, + ytd-display-ad-renderer, + ytd-promoted-video-renderer, + ytd-promoted-sparkles-web-renderer, + ytd-promoted-sparkles-text-search-renderer, + ytd-banner-promo-renderer, + ytd-statement-banner-renderer, + ytd-companion-slot-renderer, + ytd-action-companion-ad-renderer, + ytd-player-legacy-desktop-watch-ads-renderer, + ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-ads"], + ytd-merch-shelf-renderer, + ytm-companion-slot, + ytmusic-ad-slot-renderer, + ytmusic-statement-banner-renderer { + display: none !important; + } +`; + +let pollTimer = null; +// Per