Skip to content

Commit 9bb46ca

Browse files
authored
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 d793f98 commit 9bb46ca

File tree

8 files changed

+1348
-308
lines changed

8 files changed

+1348
-308
lines changed

Sources/ContentScopeScripts/dist/contentScope.js

+192-44
Original file line numberDiff line numberDiff line change
@@ -3806,78 +3806,229 @@
38063806
});
38073807

38083808
let adLabelStrings = [];
3809+
const parser = new DOMParser();
3810+
let hiddenElements = new WeakMap();
3811+
let appliedRules = new Set();
38093812

3810-
function collapseDomNode (element, type) {
3813+
/**
3814+
* Hide DOM element if rule conditions met
3815+
* @param {HTMLElement} element
3816+
* @param {Object} rule
3817+
* @param {HTMLElement} [previousElement]
3818+
*/
3819+
function collapseDomNode (element, rule, previousElement) {
38113820
if (!element) {
38123821
return
38133822
}
3823+
const type = rule.type;
3824+
const alreadyHidden = hiddenElements.has(element);
3825+
3826+
if (alreadyHidden) {
3827+
return
3828+
}
38143829

38153830
switch (type) {
38163831
case 'hide':
3817-
if (!element.hidden) {
3818-
hideNode(element);
3819-
}
3832+
hideNode(element);
38203833
break
38213834
case 'hide-empty':
3822-
if (!element.hidden && isDomNodeEmpty(element)) {
3835+
if (isDomNodeEmpty(element)) {
38233836
hideNode(element);
3837+
appliedRules.add(rule);
38243838
}
38253839
break
38263840
case 'closest-empty':
3827-
// if element already hidden, continue onto parent element
3828-
if (element.hidden) {
3829-
collapseDomNode(element.parentNode, type);
3830-
break
3841+
// hide the outermost empty node so that we may unhide if ad loads
3842+
if (isDomNodeEmpty(element)) {
3843+
collapseDomNode(element.parentNode, rule, element);
3844+
} else if (previousElement) {
3845+
hideNode(previousElement);
3846+
appliedRules.add(rule);
38313847
}
3848+
break
3849+
}
3850+
}
38323851

3833-
if (isDomNodeEmpty(element)) {
3834-
hideNode(element);
3835-
collapseDomNode(element.parentNode, type);
3852+
/**
3853+
* Unhide previously hidden DOM element if content loaded into it
3854+
* @param {HTMLElement} element
3855+
* @param {Object} rule
3856+
* @param {HTMLElement} [previousElement]
3857+
*/
3858+
function expandNonEmptyDomNode (element, rule, previousElement) {
3859+
if (!element) {
3860+
return
3861+
}
3862+
const type = rule.type;
3863+
3864+
const alreadyHidden = hiddenElements.has(element);
3865+
3866+
switch (type) {
3867+
case 'hide':
3868+
// only care about rule types that specifically apply to empty elements
3869+
break
3870+
case 'hide-empty':
3871+
case 'closest-empty':
3872+
if (alreadyHidden && !isDomNodeEmpty(element)) {
3873+
unhideNode(element);
3874+
} else if (type === 'closest-empty') {
3875+
// iterate upwards from matching DOM elements until we arrive at previously
3876+
// hidden element. Unhide element if it contains visible content.
3877+
expandNonEmptyDomNode(element.parentNode, rule);
38363878
}
38373879
break
3838-
default:
3839-
console.log(`Unsupported rule: ${type}`);
38403880
}
38413881
}
38423882

3883+
/**
3884+
* Hide DOM element
3885+
* @param {HTMLElement} element
3886+
*/
38433887
function hideNode (element) {
3888+
// maintain a reference to each hidden element along with the properties
3889+
// that are being overwritten
3890+
const cachedDisplayProperties = {
3891+
display: element.style.display,
3892+
'min-height': element.style.minHeight,
3893+
height: element.style.height
3894+
};
3895+
hiddenElements.set(element, cachedDisplayProperties);
3896+
3897+
// apply styles to hide element
38443898
element.style.setProperty('display', 'none', 'important');
3899+
element.style.setProperty('min-height', '0px', 'important');
3900+
element.style.setProperty('height', '0px', 'important');
38453901
element.hidden = true;
38463902
}
38473903

3904+
/**
3905+
* Show previously hidden DOM element
3906+
* @param {HTMLElement} element
3907+
*/
3908+
function unhideNode (element) {
3909+
const cachedDisplayProperties = hiddenElements.get(element);
3910+
if (!cachedDisplayProperties) {
3911+
return
3912+
}
3913+
3914+
for (const prop in cachedDisplayProperties) {
3915+
element.style.setProperty(prop, cachedDisplayProperties[prop]);
3916+
}
3917+
hiddenElements.delete(element);
3918+
element.hidden = false;
3919+
}
3920+
3921+
/**
3922+
* Check if DOM element contains visible content
3923+
* @param {HTMLElement} node
3924+
*/
38483925
function isDomNodeEmpty (node) {
3849-
const visibleText = node.innerText.trim().toLocaleLowerCase();
3850-
const mediaContent = node.querySelector('video,canvas');
3851-
const frameElements = [...node.querySelectorAll('iframe')];
3926+
// no sense wasting cycles checking if the page's body element is empty
3927+
if (node.tagName === 'BODY') {
3928+
return false
3929+
}
3930+
// use a DOMParser to remove all metadata elements before checking if
3931+
// the node is empty.
3932+
const parsedNode = parser.parseFromString(node.outerHTML, 'text/html').documentElement;
3933+
parsedNode.querySelectorAll('base,link,meta,script,style,template,title,desc').forEach((el) => {
3934+
el.remove();
3935+
});
3936+
3937+
const visibleText = parsedNode.innerText.trim().toLocaleLowerCase().replace(/:$/, '');
3938+
const mediaContent = parsedNode.querySelector('video,canvas,picture');
3939+
const frameElements = [...parsedNode.querySelectorAll('iframe')];
38523940
// about:blank iframes don't count as content, return true if:
38533941
// - node doesn't contain any iframes
38543942
// - node contains iframes, all of which are hidden or have src='about:blank'
38553943
const noFramesWithContent = frameElements.every((frame) => {
38563944
return (frame.hidden || frame.src === 'about:blank')
38573945
});
3946+
38583947
if ((visibleText === '' || adLabelStrings.includes(visibleText)) &&
38593948
noFramesWithContent && mediaContent === null) {
38603949
return true
38613950
}
38623951
return false
38633952
}
38643953

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

3868-
function hideMatchingNodesInner () {
3869-
rules.forEach((rule) => {
3870-
const matchingElementArray = [...document.querySelectorAll(rule.selector)];
3871-
matchingElementArray.forEach((element) => {
3872-
collapseDomNode(element, rule.type);
3873-
});
4012+
rules.forEach((rule) => {
4013+
const matchingElementArray = [...document.querySelectorAll(rule.selector)];
4014+
matchingElementArray.forEach((element) => {
4015+
collapseDomNode(element, rule);
38744016
});
3875-
}
3876-
// wait 300ms before hiding ad containers so ads have a chance to load
3877-
setTimeout(hideMatchingNodesInner, 300);
4017+
});
4018+
}
38784019

3879-
// handle any ad containers that weren't added to the page within 300ms of page load
3880-
setTimeout(hideMatchingNodesInner, 1000);
4020+
/**
4021+
* Iterate over previously hidden elements, unhiding if content has loaded into them
4022+
*/
4023+
function unhideLoadedAds () {
4024+
const document = globalThis.document;
4025+
4026+
appliedRules.forEach((rule) => {
4027+
const matchingElementArray = [...document.querySelectorAll(rule.selector)];
4028+
matchingElementArray.forEach((element) => {
4029+
expandNonEmptyDomNode(element, rule);
4030+
});
4031+
});
38814032
}
38824033

38834034
function init$c (args) {
@@ -3912,26 +4063,23 @@
39124063
// now have the final list of rules to apply, so we apply them when document is loaded
39134064
if (document.readyState === 'loading') {
39144065
window.addEventListener('DOMContentLoaded', (event) => {
3915-
hideMatchingDomNodes(activeRules);
4066+
applyRules(activeRules);
39164067
});
39174068
} else {
3918-
hideMatchingDomNodes(activeRules);
4069+
applyRules(activeRules);
39194070
}
39204071
// single page applications don't have a DOMContentLoaded event on navigations, so
3921-
// we use proxy/reflect on history.pushState and history.replaceState to call hideMatchingDomNodes
3922-
// on page navigations, and listen for popstate events that indicate a back/forward navigation
3923-
const methods = ['pushState', 'replaceState'];
3924-
for (const methodName of methods) {
3925-
const historyMethodProxy = new DDGProxy(featureName, History.prototype, methodName, {
3926-
apply (target, thisArg, args) {
3927-
hideMatchingDomNodes(activeRules);
3928-
return DDGReflect.apply(target, thisArg, args)
3929-
}
3930-
});
3931-
historyMethodProxy.overload();
3932-
}
4072+
// we use proxy/reflect on history.pushState to call applyRules on page navigations
4073+
const historyMethodProxy = new DDGProxy(featureName, History.prototype, 'pushState', {
4074+
apply (target, thisArg, args) {
4075+
applyRules(activeRules);
4076+
return DDGReflect.apply(target, thisArg, args)
4077+
}
4078+
});
4079+
historyMethodProxy.overload();
4080+
// listen for popstate events in order to run on back/forward navigations
39334081
window.addEventListener('popstate', (event) => {
3934-
hideMatchingDomNodes(activeRules);
4082+
applyRules(activeRules);
39354083
});
39364084
}
39374085

0 commit comments

Comments
 (0)