Skip to content

Commit 1f2216d

Browse files
dharbsammacbeth
authored andcommitted
Hide empty ad space improvements (#187)
* element hiding improvements * stop storing any data on dom elements, refinements to hiding technique derived from extensive testing
1 parent 589fc81 commit 1f2216d

File tree

8 files changed

+1355
-357
lines changed

8 files changed

+1355
-357
lines changed

Sources/ContentScopeScripts/dist/contentScope.js

Lines changed: 192 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3812,78 +3812,229 @@
38123812
});
38133813

38143814
let adLabelStrings = [];
3815+
const parser = new DOMParser();
3816+
let hiddenElements = new WeakMap();
3817+
let appliedRules = new Set();
38153818

3816-
function collapseDomNode (element, type) {
3819+
/**
3820+
* Hide DOM element if rule conditions met
3821+
* @param {HTMLElement} element
3822+
* @param {Object} rule
3823+
* @param {HTMLElement} [previousElement]
3824+
*/
3825+
function collapseDomNode (element, rule, previousElement) {
38173826
if (!element) {
38183827
return
38193828
}
3829+
const type = rule.type;
3830+
const alreadyHidden = hiddenElements.has(element);
3831+
3832+
if (alreadyHidden) {
3833+
return
3834+
}
38203835

38213836
switch (type) {
38223837
case 'hide':
3823-
if (!element.hidden) {
3824-
hideNode(element);
3825-
}
3838+
hideNode(element);
38263839
break
38273840
case 'hide-empty':
3828-
if (!element.hidden && isDomNodeEmpty(element)) {
3841+
if (isDomNodeEmpty(element)) {
38293842
hideNode(element);
3843+
appliedRules.add(rule);
38303844
}
38313845
break
38323846
case 'closest-empty':
3833-
// if element already hidden, continue onto parent element
3834-
if (element.hidden) {
3835-
collapseDomNode(element.parentNode, type);
3836-
break
3847+
// hide the outermost empty node so that we may unhide if ad loads
3848+
if (isDomNodeEmpty(element)) {
3849+
collapseDomNode(element.parentNode, rule, element);
3850+
} else if (previousElement) {
3851+
hideNode(previousElement);
3852+
appliedRules.add(rule);
38373853
}
3854+
break
3855+
}
3856+
}
38383857

3839-
if (isDomNodeEmpty(element)) {
3840-
hideNode(element);
3841-
collapseDomNode(element.parentNode, type);
3858+
/**
3859+
* Unhide previously hidden DOM element if content loaded into it
3860+
* @param {HTMLElement} element
3861+
* @param {Object} rule
3862+
* @param {HTMLElement} [previousElement]
3863+
*/
3864+
function expandNonEmptyDomNode (element, rule, previousElement) {
3865+
if (!element) {
3866+
return
3867+
}
3868+
const type = rule.type;
3869+
3870+
const alreadyHidden = hiddenElements.has(element);
3871+
3872+
switch (type) {
3873+
case 'hide':
3874+
// only care about rule types that specifically apply to empty elements
3875+
break
3876+
case 'hide-empty':
3877+
case 'closest-empty':
3878+
if (alreadyHidden && !isDomNodeEmpty(element)) {
3879+
unhideNode(element);
3880+
} else if (type === 'closest-empty') {
3881+
// iterate upwards from matching DOM elements until we arrive at previously
3882+
// hidden element. Unhide element if it contains visible content.
3883+
expandNonEmptyDomNode(element.parentNode, rule);
38423884
}
38433885
break
3844-
default:
3845-
console.log(`Unsupported rule: ${type}`);
38463886
}
38473887
}
38483888

3889+
/**
3890+
* Hide DOM element
3891+
* @param {HTMLElement} element
3892+
*/
38493893
function hideNode (element) {
3894+
// maintain a reference to each hidden element along with the properties
3895+
// that are being overwritten
3896+
const cachedDisplayProperties = {
3897+
display: element.style.display,
3898+
'min-height': element.style.minHeight,
3899+
height: element.style.height
3900+
};
3901+
hiddenElements.set(element, cachedDisplayProperties);
3902+
3903+
// apply styles to hide element
38503904
element.style.setProperty('display', 'none', 'important');
3905+
element.style.setProperty('min-height', '0px', 'important');
3906+
element.style.setProperty('height', '0px', 'important');
38513907
element.hidden = true;
38523908
}
38533909

3910+
/**
3911+
* Show previously hidden DOM element
3912+
* @param {HTMLElement} element
3913+
*/
3914+
function unhideNode (element) {
3915+
const cachedDisplayProperties = hiddenElements.get(element);
3916+
if (!cachedDisplayProperties) {
3917+
return
3918+
}
3919+
3920+
for (const prop in cachedDisplayProperties) {
3921+
element.style.setProperty(prop, cachedDisplayProperties[prop]);
3922+
}
3923+
hiddenElements.delete(element);
3924+
element.hidden = false;
3925+
}
3926+
3927+
/**
3928+
* Check if DOM element contains visible content
3929+
* @param {HTMLElement} node
3930+
*/
38543931
function isDomNodeEmpty (node) {
3855-
const visibleText = node.innerText.trim().toLocaleLowerCase();
3856-
const mediaContent = node.querySelector('video,canvas');
3857-
const frameElements = [...node.querySelectorAll('iframe')];
3932+
// no sense wasting cycles checking if the page's body element is empty
3933+
if (node.tagName === 'BODY') {
3934+
return false
3935+
}
3936+
// use a DOMParser to remove all metadata elements before checking if
3937+
// the node is empty.
3938+
const parsedNode = parser.parseFromString(node.outerHTML, 'text/html').documentElement;
3939+
parsedNode.querySelectorAll('base,link,meta,script,style,template,title,desc').forEach((el) => {
3940+
el.remove();
3941+
});
3942+
3943+
const visibleText = parsedNode.innerText.trim().toLocaleLowerCase().replace(/:$/, '');
3944+
const mediaContent = parsedNode.querySelector('video,canvas,picture');
3945+
const frameElements = [...parsedNode.querySelectorAll('iframe')];
38583946
// about:blank iframes don't count as content, return true if:
38593947
// - node doesn't contain any iframes
38603948
// - node contains iframes, all of which are hidden or have src='about:blank'
38613949
const noFramesWithContent = frameElements.every((frame) => {
38623950
return (frame.hidden || frame.src === 'about:blank')
38633951
});
3952+
38643953
if ((visibleText === '' || adLabelStrings.includes(visibleText)) &&
38653954
noFramesWithContent && mediaContent === null) {
38663955
return true
38673956
}
38683957
return false
38693958
}
38703959

3871-
function hideMatchingDomNodes (rules) {
3960+
/**
3961+
* Apply relevant hiding rules to page at set intervals
3962+
* @param {Object[]} rules
3963+
* @param {string} rules[].selector
3964+
* @param {string} rules[].type
3965+
*/
3966+
function applyRules (rules) {
3967+
// several passes are made to hide & unhide elements. this is necessary because we're not using
3968+
// a mutation observer but we want to hide/unhide elements as soon as possible, and ads
3969+
// frequently take from several hundred milliseconds to several seconds to load
3970+
// check at 0ms, 100ms, 200ms, 300ms, 400ms, 500ms, 1000ms, 1500ms, 2000ms, 2500ms, 3000ms
3971+
hideAdNodes(rules);
3972+
let immediateHideIterations = 0;
3973+
const immediateHideInterval = setInterval(function () {
3974+
immediateHideIterations += 1;
3975+
if (immediateHideIterations === 4) {
3976+
clearInterval(immediateHideInterval);
3977+
}
3978+
hideAdNodes(rules);
3979+
}, 100);
3980+
3981+
let delayedHideIterations = 0;
3982+
const delayedHideInterval = setInterval(function () {
3983+
delayedHideIterations += 1;
3984+
if (delayedHideIterations === 4) {
3985+
clearInterval(delayedHideInterval);
3986+
}
3987+
hideAdNodes(rules);
3988+
}, 500);
3989+
3990+
// check previously hidden ad elements for contents, unhide if content has loaded after hiding.
3991+
// we do this in order to display non-tracking ads that aren't blocked at the request level
3992+
// check at 750ms, 1500ms, 2250ms, 3000ms
3993+
let unhideIterations = 0;
3994+
const unhideInterval = setInterval(function () {
3995+
unhideIterations += 1;
3996+
if (unhideIterations === 3) {
3997+
clearInterval(unhideInterval);
3998+
}
3999+
unhideLoadedAds();
4000+
}, 750);
4001+
4002+
// clear appliedRules and hiddenElements caches once all checks have run
4003+
setTimeout(function () {
4004+
appliedRules = new Set();
4005+
hiddenElements = new WeakMap();
4006+
}, 3100);
4007+
}
4008+
4009+
/**
4010+
* Apply list of active element hiding rules to page
4011+
* @param {Object[]} rules
4012+
* @param {string} rules[].selector
4013+
* @param {string} rules[].type
4014+
*/
4015+
function hideAdNodes (rules) {
38724016
const document = globalThis.document;
38734017

3874-
function hideMatchingNodesInner () {
3875-
rules.forEach((rule) => {
3876-
const matchingElementArray = [...document.querySelectorAll(rule.selector)];
3877-
matchingElementArray.forEach((element) => {
3878-
collapseDomNode(element, rule.type);
3879-
});
4018+
rules.forEach((rule) => {
4019+
const matchingElementArray = [...document.querySelectorAll(rule.selector)];
4020+
matchingElementArray.forEach((element) => {
4021+
collapseDomNode(element, rule);
38804022
});
3881-
}
3882-
// wait 300ms before hiding ad containers so ads have a chance to load
3883-
setTimeout(hideMatchingNodesInner, 300);
4023+
});
4024+
}
38844025

3885-
// handle any ad containers that weren't added to the page within 300ms of page load
3886-
setTimeout(hideMatchingNodesInner, 1000);
4026+
/**
4027+
* Iterate over previously hidden elements, unhiding if content has loaded into them
4028+
*/
4029+
function unhideLoadedAds () {
4030+
const document = globalThis.document;
4031+
4032+
appliedRules.forEach((rule) => {
4033+
const matchingElementArray = [...document.querySelectorAll(rule.selector)];
4034+
matchingElementArray.forEach((element) => {
4035+
expandNonEmptyDomNode(element, rule);
4036+
});
4037+
});
38874038
}
38884039

38894040
function init$c (args) {
@@ -3918,26 +4069,23 @@
39184069
// now have the final list of rules to apply, so we apply them when document is loaded
39194070
if (document.readyState === 'loading') {
39204071
window.addEventListener('DOMContentLoaded', (event) => {
3921-
hideMatchingDomNodes(activeRules);
4072+
applyRules(activeRules);
39224073
});
39234074
} else {
3924-
hideMatchingDomNodes(activeRules);
4075+
applyRules(activeRules);
39254076
}
39264077
// single page applications don't have a DOMContentLoaded event on navigations, so
3927-
// we use proxy/reflect on history.pushState and history.replaceState to call hideMatchingDomNodes
3928-
// on page navigations, and listen for popstate events that indicate a back/forward navigation
3929-
const methods = ['pushState', 'replaceState'];
3930-
for (const methodName of methods) {
3931-
const historyMethodProxy = new DDGProxy(featureName, History.prototype, methodName, {
3932-
apply (target, thisArg, args) {
3933-
hideMatchingDomNodes(activeRules);
3934-
return DDGReflect.apply(target, thisArg, args)
3935-
}
3936-
});
3937-
historyMethodProxy.overload();
3938-
}
4078+
// we use proxy/reflect on history.pushState to call applyRules on page navigations
4079+
const historyMethodProxy = new DDGProxy(featureName, History.prototype, 'pushState', {
4080+
apply (target, thisArg, args) {
4081+
applyRules(activeRules);
4082+
return DDGReflect.apply(target, thisArg, args)
4083+
}
4084+
});
4085+
historyMethodProxy.overload();
4086+
// listen for popstate events in order to run on back/forward navigations
39394087
window.addEventListener('popstate', (event) => {
3940-
hideMatchingDomNodes(activeRules);
4088+
applyRules(activeRules);
39414089
});
39424090
}
39434091

0 commit comments

Comments
 (0)