/******************************************************************************* uBlock Origin - a browser extension to block requests. Copyright (C) 2014-present Raymond Hill This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see {http://www.gnu.org/licenses/}. Home: https://github.com/gorhill/uBlock */ /* jshint esversion:11 */ 'use strict'; /******************************************************************************/ // Important! // Isolate from global scope (function uBOL_cssProcedural() { /******************************************************************************/ let proceduralFilterer; /******************************************************************************/ const addStylesheet = text => { try { const sheet = new CSSStyleSheet(); sheet.replace(`@layer{${text}}`); document.adoptedStyleSheets = [ ...document.adoptedStyleSheets, sheet ]; } catch(ex) { } }; const nonVisualElements = { script: true, style: true, }; /******************************************************************************/ // 'P' stands for 'Procedural' class PSelectorTask { begin() { } end() { } } /******************************************************************************/ class PSelectorVoidTask extends PSelectorTask { constructor(task) { super(); console.info(`uBO: :${task[0]}() operator does not exist`); } transpose() { } } /******************************************************************************/ class PSelectorHasTextTask extends PSelectorTask { constructor(task) { super(); let arg0 = task[1], arg1; if ( Array.isArray(task[1]) ) { arg1 = arg0[1]; arg0 = arg0[0]; } this.needle = new RegExp(arg0, arg1); } transpose(node, output) { if ( this.needle.test(node.textContent) ) { output.push(node); } } } /******************************************************************************/ class PSelectorIfTask extends PSelectorTask { constructor(task) { super(); this.pselector = new PSelector(task[1]); } transpose(node, output) { if ( this.pselector.test(node) === this.target ) { output.push(node); } } } PSelectorIfTask.prototype.target = true; class PSelectorIfNotTask extends PSelectorIfTask { } PSelectorIfNotTask.prototype.target = false; /******************************************************************************/ class PSelectorMatchesCSSTask extends PSelectorTask { constructor(task) { super(); this.name = task[1].name; this.pseudo = task[1].pseudo ? `::${task[1].pseudo}` : null; let arg0 = task[1].value, arg1; if ( Array.isArray(arg0) ) { arg1 = arg0[1]; arg0 = arg0[0]; } this.value = new RegExp(arg0, arg1); } transpose(node, output) { const style = window.getComputedStyle(node, this.pseudo); if ( style !== null && this.value.test(style[this.name]) ) { output.push(node); } } } class PSelectorMatchesCSSAfterTask extends PSelectorMatchesCSSTask { constructor(task) { super(task); this.pseudo = '::after'; } } class PSelectorMatchesCSSBeforeTask extends PSelectorMatchesCSSTask { constructor(task) { super(task); this.pseudo = '::before'; } } /******************************************************************************/ class PSelectorMatchesMediaTask extends PSelectorTask { constructor(task) { super(); this.mql = window.matchMedia(task[1]); if ( this.mql.media === 'not all' ) { return; } this.mql.addEventListener('change', ( ) => { if ( proceduralFilterer instanceof Object === false ) { return; } proceduralFilterer.onDOMChanged([ null ]); }); } transpose(node, output) { if ( this.mql.matches === false ) { return; } output.push(node); } } /******************************************************************************/ class PSelectorMatchesPathTask extends PSelectorTask { constructor(task) { super(); let arg0 = task[1], arg1; if ( Array.isArray(task[1]) ) { arg1 = arg0[1]; arg0 = arg0[0]; } this.needle = new RegExp(arg0, arg1); } transpose(node, output) { if ( this.needle.test(self.location.pathname + self.location.search) ) { output.push(node); } } } /******************************************************************************/ class PSelectorMinTextLengthTask extends PSelectorTask { constructor(task) { super(); this.min = task[1]; } transpose(node, output) { if ( node.textContent.length >= this.min ) { output.push(node); } } } /******************************************************************************/ class PSelectorOthersTask extends PSelectorTask { constructor() { super(); this.targets = new Set(); } begin() { this.targets.clear(); } end(output) { const toKeep = new Set(this.targets); const toDiscard = new Set(); const body = document.body; let discard = null; for ( let keep of this.targets ) { while ( keep !== null && keep !== body ) { toKeep.add(keep); toDiscard.delete(keep); discard = keep.previousElementSibling; while ( discard !== null ) { if ( nonVisualElements[discard.localName] !== true && toKeep.has(discard) === false ) { toDiscard.add(discard); } discard = discard.previousElementSibling; } discard = keep.nextElementSibling; while ( discard !== null ) { if ( nonVisualElements[discard.localName] !== true && toKeep.has(discard) === false ) { toDiscard.add(discard); } discard = discard.nextElementSibling; } keep = keep.parentElement; } } for ( discard of toDiscard ) { output.push(discard); } this.targets.clear(); } transpose(candidate) { for ( const target of this.targets ) { if ( target.contains(candidate) ) { return; } if ( candidate.contains(target) ) { this.targets.delete(target); } } this.targets.add(candidate); } } /******************************************************************************/ // https://github.com/AdguardTeam/ExtendedCss/issues/31#issuecomment-302391277 // Prepend `:scope ` if needed. class PSelectorSpathTask extends PSelectorTask { constructor(task) { super(); this.spath = task[1]; this.nth = /^(?:\s*[+~]|:)/.test(this.spath); if ( this.nth ) { return; } if ( /^\s*>/.test(this.spath) ) { this.spath = `:scope ${this.spath.trim()}`; } } transpose(node, output) { const nodes = this.nth ? PSelectorSpathTask.qsa(node, this.spath) : node.querySelectorAll(this.spath); for ( const node of nodes ) { output.push(node); } } // Helper method for other operators. static qsa(node, selector) { const parent = node.parentElement; if ( parent === null ) { return []; } let pos = 1; for (;;) { node = node.previousElementSibling; if ( node === null ) { break; } pos += 1; } return parent.querySelectorAll( `:scope > :nth-child(${pos})${selector}` ); } } /******************************************************************************/ class PSelectorUpwardTask extends PSelectorTask { constructor(task) { super(); const arg = task[1]; if ( typeof arg === 'number' ) { this.i = arg; } else { this.s = arg; } } transpose(node, output) { if ( this.s !== '' ) { const parent = node.parentElement; if ( parent === null ) { return; } node = parent.closest(this.s); if ( node === null ) { return; } } else { let nth = this.i; for (;;) { node = node.parentElement; if ( node === null ) { return; } nth -= 1; if ( nth === 0 ) { break; } } } output.push(node); } } PSelectorUpwardTask.prototype.i = 0; PSelectorUpwardTask.prototype.s = ''; /******************************************************************************/ class PSelectorWatchAttrs extends PSelectorTask { constructor(task) { super(); this.observer = null; this.observed = new WeakSet(); this.observerOptions = { attributes: true, subtree: true, }; const attrs = task[1]; if ( Array.isArray(attrs) && attrs.length !== 0 ) { this.observerOptions.attributeFilter = task[1]; } } // TODO: Is it worth trying to re-apply only the current selector? handler() { if ( proceduralFilterer instanceof Object ) { proceduralFilterer.onDOMChanged([ null ]); } } transpose(node, output) { output.push(node); if ( this.observed.has(node) ) { return; } if ( this.observer === null ) { this.observer = new MutationObserver(this.handler); } this.observer.observe(node, this.observerOptions); this.observed.add(node); } } /******************************************************************************/ class PSelectorXpathTask extends PSelectorTask { constructor(task) { super(); this.xpe = document.createExpression(task[1], null); this.xpr = null; } transpose(node, output) { this.xpr = this.xpe.evaluate( node, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, this.xpr ); let j = this.xpr.snapshotLength; while ( j-- ) { const node = this.xpr.snapshotItem(j); if ( node.nodeType === 1 ) { output.push(node); } } } } /******************************************************************************/ class PSelector { constructor(o) { this.raw = o.raw; this.selector = o.selector; this.tasks = []; const tasks = []; if ( Array.isArray(o.tasks) === false ) { return; } for ( const task of o.tasks ) { const ctor = this.operatorToTaskMap.get(task[0]) || PSelectorVoidTask; tasks.push(new ctor(task)); } this.tasks = tasks; } prime(input) { const root = input || document; if ( this.selector === '' ) { return [ root ]; } if ( input !== document && /^ [>+~]/.test(this.selector) ) { return Array.from(PSelectorSpathTask.qsa(input, this.selector)); } return Array.from(root.querySelectorAll(this.selector)); } exec(input) { let nodes = this.prime(input); for ( const task of this.tasks ) { if ( nodes.length === 0 ) { break; } const transposed = []; task.begin(); for ( const node of nodes ) { task.transpose(node, transposed); } task.end(transposed); nodes = transposed; } return nodes; } test(input) { const nodes = this.prime(input); for ( const node of nodes ) { let output = [ node ]; for ( const task of this.tasks ) { const transposed = []; task.begin(); for ( const node of output ) { task.transpose(node, transposed); } task.end(transposed); output = transposed; if ( output.length === 0 ) { break; } } if ( output.length !== 0 ) { return true; } } return false; } } PSelector.prototype.operatorToTaskMap = new Map([ [ 'has', PSelectorIfTask ], [ 'has-text', PSelectorHasTextTask ], [ 'if', PSelectorIfTask ], [ 'if-not', PSelectorIfNotTask ], [ 'matches-css', PSelectorMatchesCSSTask ], [ 'matches-css-after', PSelectorMatchesCSSAfterTask ], [ 'matches-css-before', PSelectorMatchesCSSBeforeTask ], [ 'matches-media', PSelectorMatchesMediaTask ], [ 'matches-path', PSelectorMatchesPathTask ], [ 'min-text-length', PSelectorMinTextLengthTask ], [ 'not', PSelectorIfNotTask ], [ 'others', PSelectorOthersTask ], [ 'spath', PSelectorSpathTask ], [ 'upward', PSelectorUpwardTask ], [ 'watch-attr', PSelectorWatchAttrs ], [ 'xpath', PSelectorXpathTask ], ]); /******************************************************************************/ class PSelectorRoot extends PSelector { constructor(o, styleToken) { super(o); this.budget = 200; // I arbitrary picked a 1/5 second this.raw = o.raw; this.cost = 0; this.lastAllowanceTime = 0; this.styleToken = styleToken; } prime(input) { try { return super.prime(input); } catch (ex) { } return []; } } /******************************************************************************/ class ProceduralFilterer { constructor(selectors) { this.selectors = []; this.masterToken = this.randomToken(); this.styleTokenMap = new Map(); this.styledNodes = new Set(); this.timer = undefined; this.addSelectors(selectors); } addSelectors() { for ( const selector of selectors ) { let style, styleToken; if ( selector.action === undefined ) { style = 'display:none!important;'; } else if ( selector.action[0] === 'style' ) { style = selector.action[1]; } if ( style !== undefined ) { styleToken = this.styleTokenFromStyle(style); } const pselector = new PSelectorRoot(selector, styleToken); this.selectors.push(pselector); } this.onDOMChanged(); } uBOL_commitNow() { //console.time('procedural selectors/dom layout changed'); // https://github.com/uBlockOrigin/uBlock-issues/issues/341 // Be ready to unhide nodes which no longer matches any of // the procedural selectors. const toUnstyle = this.styledNodes; this.styledNodes = new Set(); let t0 = Date.now(); for ( const pselector of this.selectors.values() ) { const allowance = Math.floor((t0 - pselector.lastAllowanceTime) / 2000); if ( allowance >= 1 ) { pselector.budget += allowance * 50; if ( pselector.budget > 200 ) { pselector.budget = 200; } pselector.lastAllowanceTime = t0; } if ( pselector.budget <= 0 ) { continue; } const nodes = pselector.exec(); const t1 = Date.now(); pselector.budget += t0 - t1; if ( pselector.budget < -500 ) { console.info('uBOL: disabling %s', pselector.raw); pselector.budget = -0x7FFFFFFF; } t0 = t1; if ( nodes.length === 0 ) { continue; } this.styleNodes(nodes, pselector.styleToken); } this.unstyleNodes(toUnstyle); } styleTokenFromStyle(style) { if ( style === undefined ) { return; } let styleToken = this.styleTokenMap.get(style); if ( styleToken !== undefined ) { return styleToken; } styleToken = this.randomToken(); this.styleTokenMap.set(style, styleToken); addStylesheet( `[${this.masterToken}][${styleToken}]\n{${style}}\n`, ); return styleToken; } styleNodes(nodes, styleToken) { if ( styleToken === undefined ) { for ( const node of nodes ) { node.textContent = ''; node.remove(); } return; } for ( const node of nodes ) { node.setAttribute(this.masterToken, ''); node.setAttribute(styleToken, ''); this.styledNodes.add(node); } } unstyleNodes(nodes) { for ( const node of nodes ) { if ( this.styledNodes.has(node) ) { continue; } node.removeAttribute(this.masterToken); } } randomToken() { const n = Math.random(); return String.fromCharCode(n * 25 + 97) + Math.floor( (0.25 + n * 0.75) * Number.MAX_SAFE_INTEGER ).toString(36).slice(-8); } onDOMChanged() { if ( this.timer !== undefined ) { return; } this.timer = self.requestAnimationFrame(( ) => { this.timer = undefined; this.uBOL_commitNow(); }); } } /******************************************************************************/ const proceduralImports = self.proceduralImports || []; const lookupSelectors = (hn, out) => { for ( const { argsList, hostnamesMap } of proceduralImports ) { let argsIndices = hostnamesMap.get(hn); if ( argsIndices === undefined ) { continue; } if ( typeof argsIndices === 'number' ) { argsIndices = [ argsIndices ]; } for ( const argsIndex of argsIndices ) { const details = argsList[argsIndex]; if ( details.n && details.n.includes(hn) ) { continue; } out.push(...details.a.map(json => JSON.parse(json))); } } }; let hn; try { hn = document.location.hostname; } catch(ex) { } const selectors = []; while ( hn ) { lookupSelectors(hn, selectors); if ( hn === '*' ) { break; } const pos = hn.indexOf('.'); if ( pos !== -1 ) { hn = hn.slice(pos + 1); } else { hn = '*'; } } proceduralImports.length = 0; /******************************************************************************/ if ( selectors.length === 0 ) { return; } proceduralFilterer = new ProceduralFilterer(selectors); const observer = new MutationObserver(mutations => { let domChanged = false; for ( let i = 0; i < mutations.length && !domChanged; i++ ) { const mutation = mutations[i]; for ( const added of mutation.addedNodes ) { if ( added.nodeType !== 1 ) { continue; } domChanged = true; break; } if ( domChanged === false ) { for ( const removed of mutation.removedNodes ) { if ( removed.nodeType !== 1 ) { continue; } domChanged = true; break; } } } if ( domChanged === false ) { return; } proceduralFilterer.onDOMChanged(); }); observer.observe(document, { childList: true, subtree: true, }); /******************************************************************************/ })(); /******************************************************************************/