uBlock/src/js/contentscript-extra.js
Raymond Hill 9dece3bd30
Add new procedural cosmetic operator: :matches-path(...)
Related issue:
- https://github.com/uBlockOrigin/uBlock-issues/issues/1690

New procedural operator: `:matches-path(...)`

Description: this is a all-or-nothing passthrough operator, which
on/off behavior is dictated by whether the argument match the
path of the current location. The argument can be either plain
text to be found at any position in the path, or a literal regex
against which the path is tested.

Whereas cosmetic filters can be made specific to whole domain,
the new `:matches-path()` operator allows to further narrow
the specificity according to the path of the current document
lcoation.

Typically this procedural operator is used as first operator in
a procedural cosmetic filter, so as to ensure that no further
matching work is performed should there be no match against the
current path of the current document location.

Example of usage:

    example.com##:matches-path(/shop) p

Will hide all `p` elements when visiting `https://example.com/shop/stuff`,
but not when visiting `https://example.com/` or any other page
on `example.com` which has no instance of `/shop` in the path part
of the URL.
2021-08-21 09:41:48 -04:00

487 lines
15 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
*/
'use strict';
if (
typeof vAPI === 'object' &&
typeof vAPI.DOMProceduralFilterer !== 'object'
) {
// >>>>>>>> start of local scope
/******************************************************************************/
// TODO: Experiment/evaluate loading procedural operator code using an
// on demand approach.
// 'P' stands for 'Procedural'
const PSelectorHasTextTask = class {
constructor(task) {
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);
}
}
};
const PSelectorIfTask = class {
constructor(task) {
this.pselector = new PSelector(task[1]);
}
transpose(node, output) {
if ( this.pselector.test(node) === this.target ) {
output.push(node);
}
}
};
PSelectorIfTask.prototype.target = true;
const PSelectorIfNotTask = class extends PSelectorIfTask {
};
PSelectorIfNotTask.prototype.target = false;
const PSelectorMatchesCSSTask = class {
constructor(task) {
this.name = task[1].name;
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);
}
}
};
PSelectorMatchesCSSTask.prototype.pseudo = null;
const PSelectorMatchesCSSAfterTask = class extends PSelectorMatchesCSSTask {
};
PSelectorMatchesCSSAfterTask.prototype.pseudo = ':after';
const PSelectorMatchesCSSBeforeTask = class extends PSelectorMatchesCSSTask {
};
PSelectorMatchesCSSBeforeTask.prototype.pseudo = ':before';
const PSelectorMinTextLengthTask = class {
constructor(task) {
this.min = task[1];
}
transpose(node, output) {
if ( node.textContent.length >= this.min ) {
output.push(node);
}
}
};
const PSelectorMatchesPathTask = class {
constructor(task) {
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) ) {
output.push(node);
}
}
};
// https://github.com/AdguardTeam/ExtendedCss/issues/31#issuecomment-302391277
// Prepend `:scope ` if needed.
const PSelectorSpathTask = class {
constructor(task) {
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()}`;
}
}
qsa(node) {
if ( this.nth === false ) {
return node.querySelectorAll(this.spath);
}
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})${this.spath}`
);
}
transpose(node, output) {
const nodes = this.qsa(node);
if ( nodes === undefined ) { return; }
for ( const node of nodes ) {
output.push(node);
}
}
};
const PSelectorUpwardTask = class {
constructor(task) {
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 = '';
const PSelectorWatchAttrs = class {
constructor(task) {
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() {
const filterer =
vAPI.domFilterer && vAPI.domFilterer.proceduralFilterer;
if ( filterer instanceof Object ) {
filterer.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);
}
};
const PSelectorXpathTask = class {
constructor(task) {
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);
}
}
}
};
const PSelector = class {
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-path', PSelectorMatchesPathTask ],
[ ':min-text-length', PSelectorMinTextLengthTask ],
[ ':not', PSelectorIfNotTask ],
[ ':nth-ancestor', PSelectorUpwardTask ],
[ ':spath', PSelectorSpathTask ],
[ ':upward', PSelectorUpwardTask ],
[ ':watch-attr', PSelectorWatchAttrs ],
[ ':xpath', PSelectorXpathTask ],
]);
}
this.raw = o.raw;
this.selector = o.selector;
this.tasks = [];
const tasks = o.tasks;
if ( Array.isArray(tasks) === false ) { return; }
for ( const task of tasks ) {
this.tasks.push(
new (this.operatorToTaskMap.get(task[0]))(task)
);
}
}
prime(input) {
const root = input || document;
if ( this.selector === '' ) { return [ root ]; }
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 = [];
for ( const node of nodes ) {
task.transpose(node, 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 = [];
for ( const node of output ) {
task.transpose(node, transposed);
}
output = transposed;
if ( output.length === 0 ) { break; }
}
if ( output.length !== 0 ) { return true; }
}
return false;
}
};
PSelector.prototype.operatorToTaskMap = undefined;
const PSelectorRoot = class 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;
}
};
PSelectorRoot.prototype.hit = false;
const ProceduralFilterer = class {
constructor(domFilterer) {
this.domFilterer = domFilterer;
this.domIsReady = false;
this.domIsWatched = false;
this.mustApplySelectors = false;
this.selectors = new Map();
this.masterToken = vAPI.randomToken();
this.styleTokenMap = new Map();
this.styledNodes = new Set();
if ( vAPI.domWatcher instanceof Object ) {
vAPI.domWatcher.addListener(this);
}
}
addProceduralSelectors(selectors) {
const addedSelectors = [];
let mustCommit = this.domIsWatched;
for ( const selector of selectors ) {
if ( this.selectors.has(selector.raw) ) { continue; }
let style, styleToken;
if ( selector.action === undefined ) {
style = vAPI.hideStyle;
} 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.set(selector.raw, pselector);
addedSelectors.push(pselector);
mustCommit = true;
}
if ( mustCommit === false ) { return; }
this.mustApplySelectors = this.selectors.size !== 0;
this.domFilterer.commit();
if ( this.domFilterer.hasListeners() ) {
this.domFilterer.triggerListeners({
procedural: addedSelectors
});
}
}
commitNow() {
if ( this.selectors.size === 0 || this.domIsReady === false ) {
return;
}
this.mustApplySelectors = false;
//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('uBO: disabling %s', pselector.raw);
pselector.budget = -0x7FFFFFFF;
}
t0 = t1;
if ( nodes.length === 0 ) { continue; }
pselector.hit = true;
this.styleNodes(nodes, pselector.styleToken);
}
this.unstyleNodes(toUnstyle);
//console.timeEnd('procedural selectors/dom layout changed');
}
styleTokenFromStyle(style) {
if ( style === undefined ) { return; }
let styleToken = this.styleTokenMap.get(style);
if ( styleToken !== undefined ) { return styleToken; }
styleToken = vAPI.randomToken();
this.styleTokenMap.set(style, styleToken);
this.domFilterer.addCSS(
`[${this.masterToken}][${styleToken}]\n{${style}}`,
{ silent: true, mustInject: true }
);
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);
}
}
// TODO: Current assumption is one style per hit element. Could be an
// issue if an element has multiple styling and one styling is
// brough back. Possibly too rare to care about this for now.
unstyleNodes(nodes) {
for ( const node of nodes ) {
if ( this.styledNodes.has(node) ) { continue; }
node.removeAttribute(this.masterToken);
}
}
createProceduralFilter(o) {
return new PSelectorRoot(o);
}
onDOMCreated() {
this.domIsReady = true;
this.domFilterer.commit();
}
onDOMChanged(addedNodes, removedNodes) {
if ( this.selectors.size === 0 ) { return; }
this.mustApplySelectors =
this.mustApplySelectors ||
addedNodes.length !== 0 ||
removedNodes;
this.domFilterer.commit();
}
};
vAPI.DOMProceduralFilterer = ProceduralFilterer;
/******************************************************************************/
// >>>>>>>> end of local scope
}
/*******************************************************************************
DO NOT:
- Remove the following code
- Add code beyond the following code
Reason:
- https://github.com/gorhill/uBlock/pull/3721
- uBO never uses the return value from injected content scripts
**/
void 0;