|
3812 | 3812 | });
|
3813 | 3813 |
|
3814 | 3814 | let adLabelStrings = [];
|
| 3815 | + const parser = new DOMParser(); |
| 3816 | + let hiddenElements = new WeakMap(); |
| 3817 | + let appliedRules = new Set(); |
3815 | 3818 |
|
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) { |
3817 | 3826 | if (!element) {
|
3818 | 3827 | return
|
3819 | 3828 | }
|
| 3829 | + const type = rule.type; |
| 3830 | + const alreadyHidden = hiddenElements.has(element); |
| 3831 | + |
| 3832 | + if (alreadyHidden) { |
| 3833 | + return |
| 3834 | + } |
3820 | 3835 |
|
3821 | 3836 | switch (type) {
|
3822 | 3837 | case 'hide':
|
3823 |
| - if (!element.hidden) { |
3824 |
| - hideNode(element); |
3825 |
| - } |
| 3838 | + hideNode(element); |
3826 | 3839 | break
|
3827 | 3840 | case 'hide-empty':
|
3828 |
| - if (!element.hidden && isDomNodeEmpty(element)) { |
| 3841 | + if (isDomNodeEmpty(element)) { |
3829 | 3842 | hideNode(element);
|
| 3843 | + appliedRules.add(rule); |
3830 | 3844 | }
|
3831 | 3845 | break
|
3832 | 3846 | 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); |
3837 | 3853 | }
|
| 3854 | + break |
| 3855 | + } |
| 3856 | + } |
3838 | 3857 |
|
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); |
3842 | 3884 | }
|
3843 | 3885 | break
|
3844 |
| - default: |
3845 |
| - console.log(`Unsupported rule: ${type}`); |
3846 | 3886 | }
|
3847 | 3887 | }
|
3848 | 3888 |
|
| 3889 | + /** |
| 3890 | + * Hide DOM element |
| 3891 | + * @param {HTMLElement} element |
| 3892 | + */ |
3849 | 3893 | 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 |
3850 | 3904 | element.style.setProperty('display', 'none', 'important');
|
| 3905 | + element.style.setProperty('min-height', '0px', 'important'); |
| 3906 | + element.style.setProperty('height', '0px', 'important'); |
3851 | 3907 | element.hidden = true;
|
3852 | 3908 | }
|
3853 | 3909 |
|
| 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 | + */ |
3854 | 3931 | 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')]; |
3858 | 3946 | // about:blank iframes don't count as content, return true if:
|
3859 | 3947 | // - node doesn't contain any iframes
|
3860 | 3948 | // - node contains iframes, all of which are hidden or have src='about:blank'
|
3861 | 3949 | const noFramesWithContent = frameElements.every((frame) => {
|
3862 | 3950 | return (frame.hidden || frame.src === 'about:blank')
|
3863 | 3951 | });
|
| 3952 | + |
3864 | 3953 | if ((visibleText === '' || adLabelStrings.includes(visibleText)) &&
|
3865 | 3954 | noFramesWithContent && mediaContent === null) {
|
3866 | 3955 | return true
|
3867 | 3956 | }
|
3868 | 3957 | return false
|
3869 | 3958 | }
|
3870 | 3959 |
|
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) { |
3872 | 4016 | const document = globalThis.document;
|
3873 | 4017 |
|
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); |
3880 | 4022 | });
|
3881 |
| - } |
3882 |
| - // wait 300ms before hiding ad containers so ads have a chance to load |
3883 |
| - setTimeout(hideMatchingNodesInner, 300); |
| 4023 | + }); |
| 4024 | + } |
3884 | 4025 |
|
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 | + }); |
3887 | 4038 | }
|
3888 | 4039 |
|
3889 | 4040 | function init$c (args) {
|
|
3918 | 4069 | // now have the final list of rules to apply, so we apply them when document is loaded
|
3919 | 4070 | if (document.readyState === 'loading') {
|
3920 | 4071 | window.addEventListener('DOMContentLoaded', (event) => {
|
3921 |
| - hideMatchingDomNodes(activeRules); |
| 4072 | + applyRules(activeRules); |
3922 | 4073 | });
|
3923 | 4074 | } else {
|
3924 |
| - hideMatchingDomNodes(activeRules); |
| 4075 | + applyRules(activeRules); |
3925 | 4076 | }
|
3926 | 4077 | // 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 |
3939 | 4087 | window.addEventListener('popstate', (event) => {
|
3940 |
| - hideMatchingDomNodes(activeRules); |
| 4088 | + applyRules(activeRules); |
3941 | 4089 | });
|
3942 | 4090 | }
|
3943 | 4091 |
|
|
0 commit comments