1
0
mirror of https://github.com/gorhill/uBlock.git synced 2024-11-07 03:12:33 +01:00
uBlock/platform/mv3/scriptlets/css-procedural.js
Raymond Hill 30bd6c7bb8
Parse -abp-has as declarative if possible
With the new csstree-based parser, it should now be
safe to parse `-abp-has` as declarative. There are over
a hundred such cosmetic filters in EasyList, and we want
to have these filters declaratively enforced whenever
possible in order to let the browser do the work natively
rather than rely on JS code.
2022-10-14 13:04:47 -04:00

687 lines
21 KiB
JavaScript

/*******************************************************************************
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';
/******************************************************************************/
/// name css-specific-procedural
/******************************************************************************/
// Important!
// Isolate from global scope
(function uBOL_cssSpecificProcedural() {
/******************************************************************************/
// $rulesetId$
const argsMap = new Map(self.$argsMap$);
const hostnamesMap = new Map(self.$hostnamesMap$);
/******************************************************************************/
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) {
if ( PSelector.prototype.operatorToTaskMap === undefined ) {
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 ],
]);
}
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));
}
// Initialize only after all tasks have been successfully instantiated
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 = undefined;
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();
});
}
}
/******************************************************************************/
let hn;
try { hn = document.location.hostname; } catch(ex) { }
const selectors = [];
while ( hn ) {
if ( hostnamesMap.has(hn) ) {
let argsHashes = hostnamesMap.get(hn);
if ( typeof argsHashes === 'number' ) { argsHashes = [ argsHashes ]; }
for ( const argsHash of argsHashes ) {
const details = argsMap.get(argsHash);
if ( details.n && details.n.includes(hn) ) { continue; }
selectors.push(...details.a);
}
}
if ( hn === '*' ) { break; }
const pos = hn.indexOf('.');
if ( pos !== -1 ) {
hn = hn.slice(pos + 1);
} else {
hn = '*';
}
}
const proceduralSelectors = [];
const styleSelectors = [];
for ( const selector of selectors ) {
if ( selector.cssable ) {
styleSelectors.push(selector);
} else {
proceduralSelectors.push(selector);
}
}
/******************************************************************************/
// Declarative selectors
if ( styleSelectors.length !== 0 ) {
const cssRuleFromProcedural = details => {
const { tasks, action } = details;
let mq;
if ( tasks !== undefined ) {
if ( tasks.length > 1 ) { return; }
if ( tasks[0][0] !== 'matches-media' ) { return; }
mq = tasks[0][1];
}
let style;
if ( Array.isArray(action) ) {
if ( action[0] !== 'style' ) { return; }
style = action[1];
}
if ( mq === undefined && style === undefined ) { return; }
if ( mq === undefined ) {
return `${details.selector}\n{${style}}`;
}
if ( style === undefined ) {
return `@media ${mq} {\n${details.selector}\n{display:none!important;}\n}`;
}
return `@media ${mq} {\n${details.selector}\n{${style}}\n}`;
};
const sheetText = [];
for ( const selector of styleSelectors ) {
const ruleText = cssRuleFromProcedural(selector);
if ( ruleText === undefined ) { continue; }
sheetText.push(ruleText);
}
if ( sheetText.length !== 0 ) {
addStylesheet(sheetText.join('\n'));
}
}
/******************************************************************************/
if ( proceduralSelectors.length !== 0 ) {
proceduralFilterer = new ProceduralFilterer(proceduralSelectors);
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;
}
for ( const removed of mutation.removedNodes ) {
if ( removed.nodeType !== 1 ) { continue; }
domChanged = true;
}
}
if ( domChanged === false ) { return; }
proceduralFilterer.onDOMChanged();
});
observer.observe(document, {
childList: true,
subtree: true,
});
}
/******************************************************************************/
argsMap.clear();
hostnamesMap.clear();
/******************************************************************************/
})();
/******************************************************************************/