[mv3] Add support for procedural cosmetic filtering

This commit is contained in:
Raymond Hill 2022-09-24 11:33:04 -04:00
parent a71b71e4c8
commit 966a157d19
No known key found for this signature in database
GPG Key ID: 25E1490B761470C2
10 changed files with 1082 additions and 201 deletions

View File

@ -105,14 +105,19 @@ const toRegisterable = (fname, entry) => {
directive.excludeMatches = matchesFromHostnames(entry.excludeMatches);
}
directive.js = [ `/rulesets/js/${fname.slice(0,2)}/${fname.slice(2)}.js` ];
directive.runAt = 'document_start';
if ( (fidFromFileName(fname) & RUN_AT_BIT) !== 0 ) {
directive.runAt = 'document_end';
} else {
directive.runAt = 'document_start';
}
if ( (fidFromFileName(fname) & MAIN_WORLD_BIT) !== 0 ) {
directive.world = 'MAIN';
}
return directive;
};
const MAIN_WORLD_BIT = 0b1;
const RUN_AT_BIT = 0b10;
const MAIN_WORLD_BIT = 0b01;
/******************************************************************************/

View File

@ -367,48 +367,53 @@ function addScriptingAPIResources(id, entry, prop, fid) {
}
}
const toCSSFileId = s => uidint32(s) & ~0b1;
const toJSFileId = s => uidint32(s) | 0b1;
const toCSSFileId = s => (uidint32(s) & ~0b11) | 0b00;
const toJSFileId = s => (uidint32(s) & ~0b11) | 0b01;
const toProceduralFileId = s => (uidint32(s) & ~0b11) | 0b10;
const pathFromFileName = fname => `${scriptletDir}/${fname.slice(0,2)}/${fname.slice(2)}.js`;
/******************************************************************************/
async function processCosmeticFilters(assetDetails, mapin) {
if ( mapin === undefined ) { return 0; }
const COSMETIC_FILES_PER_RULESET = 12;
const PROCEDURAL_FILES_PER_RULESET = 4;
// This groups together selectors which are used by the same hostname.
const optimized = (filters => {
if ( filters === undefined ) { return []; }
const merge = new Map();
for ( const [ selector, details ] of filters ) {
const json = JSON.stringify(details);
let entries = merge.get(json);
if ( entries === undefined ) {
entries = new Set();
merge.set(json, entries);
}
entries.add(selector);
}
const out = [];
for ( const [ json, entries ] of merge ) {
const details = JSON.parse(json);
details.selector = Array.from(entries).join(',');
out.push(details);
}
return out;
})(mapin);
// This merges selectors which are used by the same hostnames
// This creates a map of unique selectorset => all hostnames
// including/excluding the selectorset. This allows to avoid duplication
// of css content.
const cssContentMap = new Map();
for ( const entry of optimized ) {
// ends-with 0 = css resource
const id = uidint32(entry.selector);
let details = cssContentMap.get(id);
function groupCosmeticByHostnames(mapin) {
if ( mapin === undefined ) { return []; }
const merged = new Map();
for ( const [ selector, details ] of mapin ) {
const json = JSON.stringify(details);
let entries = merged.get(json);
if ( entries === undefined ) {
entries = new Set();
merged.set(json, entries);
}
entries.add(selector);
}
const out = [];
for ( const [ json, entries ] of merged ) {
const details = JSON.parse(json);
details.selectors = Array.from(entries).sort();
out.push(details);
}
return out;
}
// This merges hostnames which have the same set of selectors.
//
// Also, we sort the hostnames to increase likelihood that selector with
// same hostnames will end up in same generated scriptlet.
function groupCosmeticBySelectors(arrayin) {
const contentMap = new Map();
for ( const entry of arrayin ) {
const id = uidint32(JSON.stringify(entry.selectors));
let details = contentMap.get(id);
if ( details === undefined ) {
details = { a: entry.selector };
cssContentMap.set(id, details);
details = { a: entry.selectors };
contentMap.set(id, details);
}
if ( entry.matches !== undefined ) {
if ( details.y === undefined ) {
@ -427,8 +432,61 @@ async function processCosmeticFilters(assetDetails, mapin) {
}
}
}
const hnSort = (a, b) =>
a.split('.').reverse().join('.').localeCompare(
b.split('.').reverse().join('.')
);
const out = Array.from(contentMap).map(a => [
a[0], {
a: a[1].a,
y: a[1].y ? Array.from(a[1].y).sort(hnSort) : undefined,
n: a[1].n ? Array.from(a[1].n) : undefined,
}
]).sort((a, b) => {
const ha = Array.isArray(a[1].y) ? a[1].y[0] : '*';
const hb = Array.isArray(b[1].y) ? b[1].y[0] : '*';
return hnSort(ha, hb);
});
return out;
}
// We do not want more than 16 CSS files per subscription, so we will
const scriptletHostnameToIdMap = (hostnames, id, map) => {
for ( const hn of hostnames ) {
const existing = map.get(hn);
if ( existing === undefined ) {
map.set(hn, id);
} else if ( Array.isArray(existing) ) {
existing.push(id);
} else {
map.set(hn, [ existing, id ]);
}
}
};
const scriptletJsonReplacer = (k, v) => {
if ( k === 'n' ) {
if ( v === undefined || v.size === 0 ) { return; }
return Array.from(v);
}
if ( v instanceof Set || v instanceof Map ) {
if ( v.size === 0 ) { return; }
return Array.from(v);
}
return v;
};
/******************************************************************************/
async function processCosmeticFilters(assetDetails, mapin) {
if ( mapin === undefined ) { return 0; }
const contentArray = groupCosmeticBySelectors(
groupCosmeticByHostnames(mapin)
);
const contentPerFile =
Math.ceil(contentArray.length / COSMETIC_FILES_PER_RULESET);
// We do not want more than n CSS files per subscription, so we will
// group multiple unrelated selectors in the same file, and distinct
// css declarations will be injected programmatically according to the
// hostname of the current document.
@ -437,55 +495,33 @@ async function processCosmeticFilters(assetDetails, mapin) {
// script and the decisions to activate the cosmetic filters will be
// done at injection time according to the document's hostname.
const originalScriptletMap = await loadAllSourceScriptlets();
const contentPerFile = Math.ceil(cssContentMap.size / 16);
const cssContentArray = Array.from(cssContentMap);
const jsonReplacer = (k, v) => {
if ( k === 'n' ) {
if ( v === undefined || v.size === 0 ) { return; }
return Array.from(v);
}
if ( v instanceof Set || v instanceof Map ) {
if ( v.size === 0 ) { return; }
return Array.from(v);
}
return v;
};
const toHostnamesMap = (hostnames, id, out) => {
for ( const hn of hostnames ) {
const existing = out.get(hn);
if ( existing === undefined ) {
out.set(hn, id);
} else if ( Array.isArray(existing) ) {
existing.push(id);
} else {
out.set(hn, [ existing, id ]);
}
}
};
const generatedFiles = [];
for ( let i = 0; i < cssContentArray.length; i += contentPerFile ) {
const slice = cssContentArray.slice(i, i + contentPerFile);
for ( let i = 0; i < contentArray.length; i += contentPerFile ) {
const slice = contentArray.slice(i, i + contentPerFile);
const argsMap = slice.map(entry => [
entry[0], { a: entry[1].a, n: entry[1].n }
entry[0],
{
a: entry[1].a ? entry[1].a.join(',\n') : undefined,
n: entry[1].n
}
]);
const hostnamesMap = new Map();
for ( const [ id, details ] of slice ) {
if ( details.y === undefined ) { continue; }
toHostnamesMap(details.y, id, hostnamesMap);
scriptletHostnameToIdMap(details.y, id, hostnamesMap);
}
const patchedScriptlet = originalScriptletMap.get('css-specific')
.replace(
'$rulesetId$',
assetDetails.id
).replace(
/\bself\.\$argsMap\$/m,
`${JSON.stringify(argsMap, jsonReplacer)}`
`${JSON.stringify(argsMap, scriptletJsonReplacer)}`
).replace(
/\bself\.\$hostnamesMap\$/m,
`${JSON.stringify(hostnamesMap, jsonReplacer)}`
`${JSON.stringify(hostnamesMap, scriptletJsonReplacer)}`
);
// ends-with 0 = css resource
const fid = toCSSFileId(patchedScriptlet);
if ( globalPatchedScriptletsSet.has(fid) === false ) {
globalPatchedScriptletsSet.add(fid);
@ -510,12 +546,91 @@ async function processCosmeticFilters(assetDetails, mapin) {
}
if ( generatedFiles.length !== 0 ) {
log(`CSS-related distinct filters: ${cssContentArray.length} distinct combined selectors`);
log(`CSS-related distinct filters: ${contentArray.length} distinct combined selectors`);
log(`CSS-related injectable files: ${generatedFiles.length}`);
log(`\t${generatedFiles.join(', ')}`);
}
return cssContentArray.length;
return contentArray.length;
}
/******************************************************************************/
async function processProceduralCosmeticFilters(assetDetails, mapin) {
if ( mapin === undefined ) { return 0; }
const contentArray = groupCosmeticBySelectors(
groupCosmeticByHostnames(mapin)
);
const contentPerFile =
Math.ceil(contentArray.length / PROCEDURAL_FILES_PER_RULESET);
// We do not want more than n CSS files per subscription, so we will
// group multiple unrelated selectors in the same file, and distinct
// css declarations will be injected programmatically according to the
// hostname of the current document.
//
// The cosmetic filters will be injected programmatically as content
// script and the decisions to activate the cosmetic filters will be
// done at injection time according to the document's hostname.
const originalScriptletMap = await loadAllSourceScriptlets();
const generatedFiles = [];
for ( let i = 0; i < contentArray.length; i += contentPerFile ) {
const slice = contentArray.slice(i, i + contentPerFile);
const argsMap = slice.map(entry => [
entry[0],
{
a: entry[1].a ? entry[1].a.map(v => JSON.parse(v)) : undefined,
n: entry[1].n
}
]);
const hostnamesMap = new Map();
for ( const [ id, details ] of slice ) {
if ( details.y === undefined ) { continue; }
scriptletHostnameToIdMap(details.y, id, hostnamesMap);
}
const patchedScriptlet = originalScriptletMap.get('css-specific-procedural')
.replace(
'$rulesetId$',
assetDetails.id
).replace(
/\bself\.\$argsMap\$/m,
`${JSON.stringify(argsMap, scriptletJsonReplacer)}`
).replace(
/\bself\.\$hostnamesMap\$/m,
`${JSON.stringify(hostnamesMap, scriptletJsonReplacer)}`
);
const fid = toProceduralFileId(patchedScriptlet);
if ( globalPatchedScriptletsSet.has(fid) === false ) {
globalPatchedScriptletsSet.add(fid);
const fname = fnameFromFileId(fid);
writeFile(pathFromFileName(fname), patchedScriptlet, {});
generatedFiles.push(fname);
}
for ( const entry of slice ) {
addScriptingAPIResources(
assetDetails.id,
{ matches: entry[1].y },
'matches',
fid
);
addScriptingAPIResources(
assetDetails.id,
{ excludeMatches: entry[1].n },
'excludeMatches',
fid
);
}
}
if ( generatedFiles.length !== 0 ) {
log(`Pprocedural-related distinct filters: ${contentArray.length} distinct combined selectors`);
log(`Pprocedural-related injectable files: ${generatedFiles.length}`);
log(`\t${generatedFiles.join(', ')}`);
}
return contentArray.length;
}
/******************************************************************************/
@ -605,31 +720,6 @@ async function processScriptletFilters(assetDetails, mapin) {
const generatedFiles = [];
const jsonReplacer = (k, v) => {
if ( k === 'n' ) {
if ( v.size === 0 ) { return; }
return Array.from(v);
}
if ( v instanceof Set || v instanceof Map ) {
if ( v.size === 0 ) { return; }
return Array.from(v);
}
return v;
};
const toHostnamesMap = (hostnames, hash, out) => {
for ( const hn of hostnames ) {
const existing = out.get(hn);
if ( existing === undefined ) {
out.set(hn, hash);
} else if ( Array.isArray(existing) ) {
existing.push(hash);
} else {
out.set(hn, [ existing, hash ]);
}
}
};
for ( const [ token, argsDetails ] of scriptletDetails ) {
const argsMap = Array.from(argsDetails).map(entry => [
uidint32(entry[0]),
@ -637,15 +727,18 @@ async function processScriptletFilters(assetDetails, mapin) {
]);
const hostnamesMap = new Map();
for ( const [ argsHash, details ] of argsDetails ) {
toHostnamesMap(details.y, uidint32(argsHash), hostnamesMap);
scriptletHostnameToIdMap(details.y, uidint32(argsHash), hostnamesMap);
}
const patchedScriptlet = originalScriptletMap.get(token)
.replace(
'$rulesetId$',
assetDetails.id
).replace(
/\bself\.\$argsMap\$/m,
`${JSON.stringify(argsMap, jsonReplacer)}`
`${JSON.stringify(argsMap, scriptletJsonReplacer)}`
).replace(
/\bself\.\$hostnamesMap\$/m,
`${JSON.stringify(hostnamesMap, jsonReplacer)}`
`${JSON.stringify(hostnamesMap, scriptletJsonReplacer)}`
);
// ends-with 1 = scriptlet resource
const fid = toJSFileId(patchedScriptlet);
@ -684,6 +777,95 @@ async function processScriptletFilters(assetDetails, mapin) {
/******************************************************************************/
const rulesetFromURLS = async function(assetDetails) {
log('============================');
log(`Listset for '${assetDetails.id}':`);
const text = await fetchAsset(assetDetails);
const results = await dnrRulesetFromRawLists(
[ { name: assetDetails.id, text } ],
{ env }
);
const netStats = await processNetworkFilters(
assetDetails,
results.network
);
// Split cosmetic filters into two groups: declarative and procedural
const declarativeCosmetic = new Map();
const proceduralCosmetic = new Map();
const rejectedCosmetic = [];
if ( results.cosmetic ) {
for ( const [ selector, details ] of results.cosmetic ) {
if ( details.rejected ) {
rejectedCosmetic.push(selector);
continue;
}
if ( selector.startsWith('{') === false ) {
declarativeCosmetic.set(selector, details);
continue;
}
const parsed = JSON.parse(selector);
parsed.raw = undefined;
proceduralCosmetic.set(JSON.stringify(parsed), details);
}
}
const cosmeticStats = await processCosmeticFilters(
assetDetails,
declarativeCosmetic
);
const proceduralStats = await processProceduralCosmeticFilters(
assetDetails,
proceduralCosmetic
);
if ( rejectedCosmetic.length !== 0 ) {
log(`Rejected cosmetic filters: ${rejectedCosmetic.length}`);
log(rejectedCosmetic.map(line => `\t${line}`).join('\n'));
}
const scriptletStats = await processScriptletFilters(
assetDetails,
results.scriptlet
);
rulesetDetails.push({
id: assetDetails.id,
name: assetDetails.name,
enabled: assetDetails.enabled,
lang: assetDetails.lang,
homeURL: assetDetails.homeURL,
filters: {
total: results.network.filterCount,
accepted: results.network.acceptedFilterCount,
rejected: results.network.rejectedFilterCount,
},
rules: {
total: netStats.total,
accepted: netStats.accepted,
discarded: netStats.discarded,
rejected: netStats.rejected,
regexes: netStats.regexes,
},
css: {
specific: cosmeticStats,
procedural: proceduralStats,
},
scriptlets: {
total: scriptletStats,
},
});
ruleResources.push({
id: assetDetails.id,
enabled: assetDetails.enabled,
path: `/rulesets/${assetDetails.id}.json`
});
};
/******************************************************************************/
async function main() {
// Get manifest content
@ -706,65 +888,6 @@ async function main() {
}
log(`Version: ${version}`);
const rulesetFromURLS = async function(assetDetails) {
log('============================');
log(`Listset for '${assetDetails.id}':`);
const text = await fetchAsset(assetDetails);
const results = await dnrRulesetFromRawLists(
[ { name: assetDetails.id, text } ],
{ env }
);
const netStats = await processNetworkFilters(
assetDetails,
results.network
);
const cosmeticStats = await processCosmeticFilters(
assetDetails,
results.cosmetic
);
const scriptletStats = await processScriptletFilters(
assetDetails,
results.scriptlet
);
rulesetDetails.push({
id: assetDetails.id,
name: assetDetails.name,
enabled: assetDetails.enabled,
lang: assetDetails.lang,
homeURL: assetDetails.homeURL,
filters: {
total: results.network.filterCount,
accepted: results.network.acceptedFilterCount,
rejected: results.network.rejectedFilterCount,
},
rules: {
total: netStats.total,
accepted: netStats.accepted,
discarded: netStats.discarded,
rejected: netStats.rejected,
regexes: netStats.regexes,
},
css: {
specific: cosmeticStats,
},
scriptlets: {
total: scriptletStats,
},
});
ruleResources.push({
id: assetDetails.id,
enabled: assetDetails.enabled,
path: `/rulesets/${assetDetails.id}.json`
});
};
// Get assets.json content
const assets = await fs.readFile(
`./assets.json`,

View File

@ -41,6 +41,14 @@
/******************************************************************************/
// $rulesetId$
const argsMap = new Map(self.$argsMap$);
const hostnamesMap = new Map(self.$hostnamesMap$);
/******************************************************************************/
// Issues to mind before changing anything:
// https://github.com/uBlockOrigin/uBlock-issues/issues/2154
@ -145,10 +153,6 @@ const scriptlet = (
/******************************************************************************/
const argsMap = new Map(self.$argsMap$);
const hostnamesMap = new Map(self.$hostnamesMap$);
let hn;
try { hn = document.location.hostname; } catch(ex) { }
while ( hn ) {
@ -161,13 +165,22 @@ while ( hn ) {
try { scriptlet(...details.a); } catch(ex) {}
}
}
if ( hn === '*' ) { break; }
const pos = hn.indexOf('.');
if ( pos === -1 ) { break; }
hn = hn.slice(pos + 1);
if ( pos !== -1 ) {
hn = hn.slice(pos + 1);
} else {
hn = '*';
}
}
/******************************************************************************/
argsMap.clear();
hostnamesMap.clear();
/******************************************************************************/
})();
/******************************************************************************/

View File

@ -39,6 +39,14 @@
/******************************************************************************/
// $rulesetId$
const argsMap = new Map(self.$argsMap$);
const hostnamesMap = new Map(self.$hostnamesMap$);
/******************************************************************************/
const ObjGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
const ObjDefineProperty = Object.defineProperty;
@ -103,10 +111,6 @@ const scriptlet = (
/******************************************************************************/
const argsMap = new Map(self.$argsMap$);
const hostnamesMap = new Map(self.$hostnamesMap$);
let hn;
try { hn = document.location.hostname; } catch(ex) { }
while ( hn ) {
@ -119,13 +123,22 @@ while ( hn ) {
try { scriptlet(...details.a); } catch(ex) {}
}
}
if ( hn === '*' ) { break; }
const pos = hn.indexOf('.');
if ( pos === -1 ) { break; }
hn = hn.slice(pos + 1);
if ( pos !== -1 ) {
hn = hn.slice(pos + 1);
} else {
hn = '*';
}
}
/******************************************************************************/
argsMap.clear();
hostnamesMap.clear();
/******************************************************************************/
})();
/******************************************************************************/

View File

@ -0,0 +1,683 @@
/*******************************************************************************
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() {
/******************************************************************************/
// $rulesetId$
const argsMap = new Map(self.$argsMap$);
const hostnamesMap = new Map(self.$hostnamesMap$);
/******************************************************************************/
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 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 PSelectorMatchesMediaTask extends PSelectorTask {
constructor(task) {
super();
this.mql = window.matchMedia(task[1]);
if ( this.mql.media === 'not all' ) { return; }
this.mql.addEventListener('change', ( ) => {
if ( typeof vAPI !== 'object' ) { return; }
if ( vAPI === null ) { return; }
const filterer = vAPI.domFilterer && vAPI.domFilterer.proceduralFilterer;
if ( filterer instanceof Object === false ) { return; }
filterer.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()}`;
}
}
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);
}
}
// 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() {
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);
}
}
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-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]);
if ( ctor === undefined ) { return; }
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 ]; }
let selector = this.selector;
if ( input !== document && /^ [>+~]/.test(this.selector) ) {
return Array.from(PSelectorSpathTask.qsa(input, this.selector));
}
const elems = root.querySelectorAll(selector);
return Array.from(elems);
}
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();
}
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.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'));
}
}
/******************************************************************************/
// Procedural selectors
if ( proceduralSelectors.length !== 0 ) {
const filterer = 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; }
filterer.onDOMChanged();
});
observer.observe(document, {
childList: true,
subtree: true,
});
}
/******************************************************************************/
argsMap.clear();
hostnamesMap.clear();
/******************************************************************************/
})();
/******************************************************************************/

View File

@ -17,9 +17,6 @@
along with this program. If not, see {http://www.gnu.org/licenses/}.
Home: https://github.com/gorhill/uBlock
The scriptlets below are meant to be injected only into a
web page context.
*/
/* jshint esversion:11 */
@ -38,10 +35,14 @@
/******************************************************************************/
// $rulesetId$
const argsMap = new Map(self.$argsMap$);
const hostnamesMap = new Map(self.$hostnamesMap$);
/******************************************************************************/
let hn;
try { hn = document.location.hostname; } catch(ex) { }
const styles = [];
@ -55,9 +56,13 @@ while ( hn ) {
styles.push(details.a);
}
}
if ( hn === '*' ) { break; }
const pos = hn.indexOf('.');
if ( pos === -1 ) { break; }
hn = hn.slice(pos + 1);
if ( pos !== -1 ) {
hn = hn.slice(pos + 1);
} else {
hn = '*';
}
}
if ( styles.length === 0 ) { return; }
@ -74,6 +79,11 @@ try {
/******************************************************************************/
argsMap.clear();
hostnamesMap.clear();
/******************************************************************************/
})();
/******************************************************************************/

View File

@ -38,6 +38,14 @@
/******************************************************************************/
// $rulesetId$
const argsMap = new Map(self.$argsMap$);
const hostnamesMap = new Map(self.$hostnamesMap$);
/******************************************************************************/
// https://github.com/uBlockOrigin/uBlock-issues/issues/1545
// - Add support for "remove everything if needle matches" case
@ -121,10 +129,6 @@ const scriptlet = (
/******************************************************************************/
const argsMap = new Map(self.$argsMap$);
const hostnamesMap = new Map(self.$hostnamesMap$);
let hn;
try { hn = document.location.hostname; } catch(ex) { }
while ( hn ) {
@ -137,13 +141,22 @@ while ( hn ) {
try { scriptlet(...details.a); } catch(ex) {}
}
}
if ( hn === '*' ) { break; }
const pos = hn.indexOf('.');
if ( pos === -1 ) { break; }
hn = hn.slice(pos + 1);
if ( pos !== -1 ) {
hn = hn.slice(pos + 1);
} else {
hn = '*';
}
}
/******************************************************************************/
argsMap.clear();
hostnamesMap.clear();
/******************************************************************************/
})();
/******************************************************************************/

View File

@ -39,6 +39,14 @@
/******************************************************************************/
// $rulesetId$
const argsMap = new Map(self.$argsMap$);
const hostnamesMap = new Map(self.$hostnamesMap$);
/******************************************************************************/
const scriptlet = (
chain = '',
cValue = ''
@ -163,10 +171,6 @@ const scriptlet = (
/******************************************************************************/
const argsMap = new Map(self.$argsMap$);
const hostnamesMap = new Map(self.$hostnamesMap$);
let hn;
try { hn = document.location.hostname; } catch(ex) { }
while ( hn ) {
@ -179,13 +183,22 @@ while ( hn ) {
try { scriptlet(...details.a); } catch(ex) {}
}
}
if ( hn === '*' ) { break; }
const pos = hn.indexOf('.');
if ( pos === -1 ) { break; }
hn = hn.slice(pos + 1);
if ( pos !== -1 ) {
hn = hn.slice(pos + 1);
} else {
hn = '*';
}
}
/******************************************************************************/
argsMap.clear();
hostnamesMap.clear();
/******************************************************************************/
})();
/******************************************************************************/

View File

@ -37,10 +37,11 @@ import {
function addExtendedToDNR(context, parser) {
if ( parser.category !== parser.CATStaticExtFilter ) { return false; }
if ( (parser.flavorBits & parser.BITFlavorUnsupported) !== 0 ) { return; }
// Scriptlet injection
if ( (parser.flavorBits & parser.BITFlavorExtScriptlet) !== 0 ) {
if ( (parser.flavorBits & parser.BITFlavorUnsupported) !== 0 ) {
return;
}
if ( parser.hasOptions() === false ) { return; }
if ( context.scriptletFilters === undefined ) {
context.scriptletFilters = new Map();
@ -95,16 +96,24 @@ function addExtendedToDNR(context, parser) {
// of same filter OR globally if there is no non-negated hostnames.
for ( const { hn, not, bad } of parser.extOptions() ) {
if ( bad ) { continue; }
if ( hn.endsWith('.*') ) { continue; }
const { compiled, exception } = parser.result;
if ( typeof compiled !== 'string' ) { continue; }
if ( compiled.startsWith('{') ) { continue; }
let { compiled, exception, raw } = parser.result;
if ( exception ) { continue; }
let rejected;
if ( compiled === undefined ) {
rejected = `Invalid filter: ${hn}##${raw}`;
} else if ( hn.endsWith('.*') ) {
rejected = `Entity not supported: ${hn}##${raw}`;
}
if ( rejected ) {
compiled = rejected;
}
let details = context.cosmeticFilters.get(compiled);
if ( details === undefined ) {
details = {};
if ( rejected ) { details.rejected = true; }
context.cosmeticFilters.set(compiled, details);
}
if ( rejected ) { continue; }
if ( not ) {
if ( details.excludeMatches === undefined ) {
details.excludeMatches = [];

View File

@ -1436,14 +1436,13 @@ Parser.prototype.SelectorCompiler = class {
}
out.compiled = this.compileSelector(raw);
if ( out.compiled === undefined ) {
console.log('Error:', raw);
return false;
}
if ( out.compiled === undefined ) { return false; }
if ( out.compiled instanceof Object ) {
out.compiled.raw = raw;
out.compiled = JSON.stringify(out.compiled);
}
return true;
}