|
3806 | 3806 | });
|
3807 | 3807 |
|
3808 | 3808 | let adLabelStrings = [];
|
| 3809 | + const parser = new DOMParser(); |
| 3810 | + let hiddenElements = new WeakMap(); |
| 3811 | + let appliedRules = new Set(); |
3809 | 3812 |
|
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) { |
3811 | 3820 | if (!element) {
|
3812 | 3821 | return
|
3813 | 3822 | }
|
| 3823 | + const type = rule.type; |
| 3824 | + const alreadyHidden = hiddenElements.has(element); |
| 3825 | + |
| 3826 | + if (alreadyHidden) { |
| 3827 | + return |
| 3828 | + } |
3814 | 3829 |
|
3815 | 3830 | switch (type) {
|
3816 | 3831 | case 'hide':
|
3817 |
| - if (!element.hidden) { |
3818 |
| - hideNode(element); |
3819 |
| - } |
| 3832 | + hideNode(element); |
3820 | 3833 | break
|
3821 | 3834 | case 'hide-empty':
|
3822 |
| - if (!element.hidden && isDomNodeEmpty(element)) { |
| 3835 | + if (isDomNodeEmpty(element)) { |
3823 | 3836 | hideNode(element);
|
| 3837 | + appliedRules.add(rule); |
3824 | 3838 | }
|
3825 | 3839 | break
|
3826 | 3840 | 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); |
3831 | 3847 | }
|
| 3848 | + break |
| 3849 | + } |
| 3850 | + } |
3832 | 3851 |
|
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); |
3836 | 3878 | }
|
3837 | 3879 | break
|
3838 |
| - default: |
3839 |
| - console.log(`Unsupported rule: ${type}`); |
3840 | 3880 | }
|
3841 | 3881 | }
|
3842 | 3882 |
|
| 3883 | + /** |
| 3884 | + * Hide DOM element |
| 3885 | + * @param {HTMLElement} element |
| 3886 | + */ |
3843 | 3887 | 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 |
3844 | 3898 | element.style.setProperty('display', 'none', 'important');
|
| 3899 | + element.style.setProperty('min-height', '0px', 'important'); |
| 3900 | + element.style.setProperty('height', '0px', 'important'); |
3845 | 3901 | element.hidden = true;
|
3846 | 3902 | }
|
3847 | 3903 |
|
| 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 | + */ |
3848 | 3925 | 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')]; |
3852 | 3940 | // about:blank iframes don't count as content, return true if:
|
3853 | 3941 | // - node doesn't contain any iframes
|
3854 | 3942 | // - node contains iframes, all of which are hidden or have src='about:blank'
|
3855 | 3943 | const noFramesWithContent = frameElements.every((frame) => {
|
3856 | 3944 | return (frame.hidden || frame.src === 'about:blank')
|
3857 | 3945 | });
|
| 3946 | + |
3858 | 3947 | if ((visibleText === '' || adLabelStrings.includes(visibleText)) &&
|
3859 | 3948 | noFramesWithContent && mediaContent === null) {
|
3860 | 3949 | return true
|
3861 | 3950 | }
|
3862 | 3951 | return false
|
3863 | 3952 | }
|
3864 | 3953 |
|
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) { |
3866 | 4010 | const document = globalThis.document;
|
3867 | 4011 |
|
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); |
3874 | 4016 | });
|
3875 |
| - } |
3876 |
| - // wait 300ms before hiding ad containers so ads have a chance to load |
3877 |
| - setTimeout(hideMatchingNodesInner, 300); |
| 4017 | + }); |
| 4018 | + } |
3878 | 4019 |
|
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 | + }); |
3881 | 4032 | }
|
3882 | 4033 |
|
3883 | 4034 | function init$c (args) {
|
|
3912 | 4063 | // now have the final list of rules to apply, so we apply them when document is loaded
|
3913 | 4064 | if (document.readyState === 'loading') {
|
3914 | 4065 | window.addEventListener('DOMContentLoaded', (event) => {
|
3915 |
| - hideMatchingDomNodes(activeRules); |
| 4066 | + applyRules(activeRules); |
3916 | 4067 | });
|
3917 | 4068 | } else {
|
3918 |
| - hideMatchingDomNodes(activeRules); |
| 4069 | + applyRules(activeRules); |
3919 | 4070 | }
|
3920 | 4071 | // 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 |
3933 | 4081 | window.addEventListener('popstate', (event) => {
|
3934 |
| - hideMatchingDomNodes(activeRules); |
| 4082 | + applyRules(activeRules); |
3935 | 4083 | });
|
3936 | 4084 | }
|
3937 | 4085 |
|
|
0 commit comments