mirror of
https://github.com/gorhill/uBlock.git
synced 2024-11-07 03:12:33 +01:00
30bd6c7bb8
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.
687 lines
21 KiB
JavaScript
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();
|
|
|
|
/******************************************************************************/
|
|
|
|
})();
|
|
|
|
/******************************************************************************/
|