Skip to content

Commit 4c3dd55

Browse files
authored
Collapse empty ad spaces (element hiding) (#155)
This PR adds the element-hiding.js content script, which is injected into pages to hide empty ad spaces. This is similar functionality to what most ad blockers call 'cosmetic filtering' with one key caveat: we are only hiding spaces where ads didn't load due to blocked tracker requests. To accomplish this we use a heuristic to check each ad container selector for visible content, and only hide if no visible content (eg non-tracking ads) is found. Initially this feature will only be enabled in the macOS app, with other client support to come as follow ups. This feature is v1, we will likely follow up with a more sophisticated approach in the near future. This script functions based on Privacy Configuration rules so that coverage may be expanded remotely without requiring updates to this repo or clients. To see this functionality in action, navigate to a site that has a large blank space at the top of the page where a tracking banner ad didn't load, and see that the empty space is hidden shortly after page load. A few example sites to check are bloomberg.com, cyclingtips.com, and fandom.com. Asana link: https://app.asana.com/0/0/1203092629639220/f Corresponding privacy config PR: duckduckgo/privacy-configuration#508
1 parent 2618d5a commit 4c3dd55

File tree

9 files changed

+986
-32
lines changed

9 files changed

+986
-32
lines changed

Sources/ContentScopeScripts/dist/contentScope.js

Lines changed: 142 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1814,6 +1814,7 @@
18141814
function __variableDynamicImportRuntime0__(path) {
18151815
switch (path) {
18161816
case './features/cookie.js': return Promise.resolve().then(function () { return cookie; });
1817+
case './features/element-hiding.js': return Promise.resolve().then(function () { return elementHiding; });
18171818
case './features/fingerprinting-audio.js': return Promise.resolve().then(function () { return fingerprintingAudio; });
18181819
case './features/fingerprinting-battery.js': return Promise.resolve().then(function () { return fingerprintingBattery; });
18191820
case './features/fingerprinting-canvas.js': return Promise.resolve().then(function () { return fingerprintingCanvas; });
@@ -1863,7 +1864,8 @@
18631864
'referrer',
18641865
'fingerprintingScreenSize',
18651866
'fingerprintingTemporaryStorage',
1866-
'navigatorInterface'
1867+
'navigatorInterface',
1868+
'elementHiding'
18671869
];
18681870

18691871
for (const featureName of featureNames) {
@@ -1878,7 +1880,7 @@
18781880
}
18791881
}
18801882

1881-
async function init$d (args) {
1883+
async function init$e (args) {
18821884
initArgs = args;
18831885
if (!shouldRun()) {
18841886
return
@@ -2234,7 +2236,7 @@
22342236
});
22352237
}
22362238

2237-
function init$c (args) {
2239+
function init$d (args) {
22382240
args.cookie.debug = args.debug;
22392241
cookiePolicy = args.cookie;
22402242

@@ -2258,10 +2260,145 @@
22582260
var cookie = /*#__PURE__*/Object.freeze({
22592261
__proto__: null,
22602262
load: load,
2261-
init: init$c,
2263+
init: init$d,
22622264
update: update
22632265
});
22642266

2267+
let adLabelStrings = [];
2268+
2269+
function collapseDomNode (element, type) {
2270+
if (!element) {
2271+
return
2272+
}
2273+
2274+
switch (type) {
2275+
case 'hide':
2276+
if (!element.hidden) {
2277+
hideNode(element);
2278+
}
2279+
break
2280+
case 'hide-empty':
2281+
if (!element.hidden && isDomNodeEmpty(element)) {
2282+
hideNode(element);
2283+
}
2284+
break
2285+
case 'closest-empty':
2286+
// if element already hidden, continue onto parent element
2287+
if (element.hidden) {
2288+
collapseDomNode(element.parentNode, type);
2289+
break
2290+
}
2291+
2292+
if (isDomNodeEmpty(element)) {
2293+
hideNode(element);
2294+
collapseDomNode(element.parentNode, type);
2295+
}
2296+
break
2297+
default:
2298+
console.log(`Unsupported rule: ${type}`);
2299+
}
2300+
}
2301+
2302+
function hideNode (element) {
2303+
element.style.setProperty('display', 'none', 'important');
2304+
element.hidden = true;
2305+
}
2306+
2307+
function isDomNodeEmpty (node) {
2308+
const visibleText = node.innerText.trim().toLocaleLowerCase();
2309+
const mediaContent = node.querySelector('video,canvas');
2310+
const frameElements = [...node.querySelectorAll('iframe')];
2311+
// about:blank iframes don't count as content, return true if:
2312+
// - node doesn't contain any iframes
2313+
// - node contains iframes, all of which are hidden or have src='about:blank'
2314+
const noFramesWithContent = frameElements.every((frame) => {
2315+
return (frame.hidden || frame.src === 'about:blank')
2316+
});
2317+
if ((visibleText === '' || adLabelStrings.includes(visibleText)) &&
2318+
noFramesWithContent && mediaContent === null) {
2319+
return true
2320+
}
2321+
return false
2322+
}
2323+
2324+
function hideMatchingDomNodes (rules) {
2325+
const document = globalThis.document;
2326+
2327+
function hideMatchingNodesInner () {
2328+
rules.forEach((rule) => {
2329+
const matchingElementArray = [...document.querySelectorAll(rule.selector)];
2330+
matchingElementArray.forEach((element) => {
2331+
collapseDomNode(element, rule.type);
2332+
});
2333+
});
2334+
}
2335+
// wait 300ms before hiding ad containers so ads have a chance to load
2336+
setTimeout(hideMatchingNodesInner, 300);
2337+
2338+
// handle any ad containers that weren't added to the page within 300ms of page load
2339+
setTimeout(hideMatchingNodesInner, 1000);
2340+
}
2341+
2342+
function init$c (args) {
2343+
if (isBeingFramed()) {
2344+
return
2345+
}
2346+
2347+
const featureName = 'elementHiding';
2348+
const domain = args.site.domain;
2349+
const domainRules = getFeatureSetting(featureName, args, 'domains');
2350+
const globalRules = getFeatureSetting(featureName, args, 'rules');
2351+
adLabelStrings = getFeatureSetting(featureName, args, 'adLabelStrings');
2352+
2353+
// collect all matching rules for domain
2354+
const activeDomainRules = domainRules.filter((rule) => {
2355+
return matchHostname(domain, rule.domain)
2356+
}).flatMap((item) => item.rules);
2357+
2358+
const overrideRules = activeDomainRules.filter((rule) => {
2359+
return rule.type === 'override'
2360+
});
2361+
2362+
let activeRules = activeDomainRules.concat(globalRules);
2363+
2364+
// remove overrides and rules that match overrides from array of rules to be applied to page
2365+
overrideRules.forEach((override) => {
2366+
activeRules = activeRules.filter((rule) => {
2367+
return rule.selector !== override.selector
2368+
});
2369+
});
2370+
2371+
// now have the final list of rules to apply, so we apply them when document is loaded
2372+
if (document.readyState === 'loading') {
2373+
window.addEventListener('DOMContentLoaded', (event) => {
2374+
hideMatchingDomNodes(activeRules);
2375+
});
2376+
} else {
2377+
hideMatchingDomNodes(activeRules);
2378+
}
2379+
// single page applications don't have a DOMContentLoaded event on navigations, so
2380+
// we use proxy/reflect on history.pushState and history.replaceState to call hideMatchingDomNodes
2381+
// on page navigations, and listen for popstate events that indicate a back/forward navigation
2382+
const methods = ['pushState', 'replaceState'];
2383+
for (const methodName of methods) {
2384+
const historyMethodProxy = new DDGProxy(featureName, History.prototype, methodName, {
2385+
apply (target, thisArg, args) {
2386+
hideMatchingDomNodes(activeRules);
2387+
return DDGReflect.apply(target, thisArg, args)
2388+
}
2389+
});
2390+
historyMethodProxy.overload();
2391+
}
2392+
window.addEventListener('popstate', (event) => {
2393+
hideMatchingDomNodes(activeRules);
2394+
});
2395+
}
2396+
2397+
var elementHiding = /*#__PURE__*/Object.freeze({
2398+
__proto__: null,
2399+
init: init$c
2400+
});
2401+
22652402
function init$b (args) {
22662403
const { sessionKey, site } = args;
22672404
const domainKey = site.domain;
@@ -4459,7 +4596,7 @@
44594596
init: init
44604597
});
44614598

4462-
exports.init = init$d;
4599+
exports.init = init$e;
44634600
exports.load = load$1;
44644601
exports.update = update$1;
44654602

build/android/contentScope.js

Lines changed: 142 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)