Raw Source
drez3000 / GDPR Blaster

// ==UserScript==
// @name         GDPR Blaster
// @description  Automatically remove GDPR / Cookie Consent dialogs without accepting or denying prompts.
// @namespace    drez3000
// @author       drez3000
// @copyright    2024, drez3000 (https://github.com/drez3000)
// @license      MIT
// @tag          productivity
// @match        *://*/*
// @grant        none
// @version      0.2.0
// @updateURL    https://raw.githubusercontent.com/drez3000/gdpr-blaster-userscript/main/src/userscript.user.js
// @downloadURL  https://raw.githubusercontent.com/drez3000/gdpr-blaster-userscript/main/src/userscript.user.js
// ==/UserScript==

; (() => {
	'use strict'

	const MAX_ATTEMPTS = 16
	const MAX_TRIGGERS = 7
	const MAX_DIALOG_REMOVALS = 5
	const MAX_OVERLAY_REMOVALS = 5
	const MAX_DIALOG_REMOVALS_PER_PASS = 2
	const MAX_OVERLAY_REMOVALS_PER_PASS = 2

	const MIN_CHECK_DELAY_MS = 50
	const MAX_CHECK_DELAY_MS = 300
	const MAX_SHADOW_ROOT_CRAWL_DEPTH = 24
	const MAX_OVERLAY_REMOVAL_DEPTH = 12

	let dialogsClosed = 0
	let overlaysClosed = 0
	let triggeredCount = 0

	function all(conditions, item) {
		return conditions.find((f) => !f(item)) === undefined
	}

	function any(conditions, item) {
		return conditions.find((f) => f(item)) !== undefined
	}

	function oncePageLoaded(callback) {
		if (document.readyState !== 'loading') {
			// Document is already ready, call the callback immediately
			callback()
		} else {
			// Document is not ready yet, wait for the DOMContentLoaded event
			document.addEventListener('DOMContentLoaded', callback)
		}
	}

	function flatNodesOf(node, { minDepth = 0, maxDepth = Number.POSITIVE_INFINITY, includeShadowRoot = true } = {}) {
		const nodes = []
		const stack = [{ node, depth: 0 }]
		while (stack.length > 0) {
			const { node: currentNode, depth } = stack.pop()

			if (depth >= minDepth && depth <= maxDepth) {
				nodes.push({ node: currentNode, depth })
			}

			// Add children to the stack with increased depth
			for (let i = currentNode.childNodes.length - 1; i >= 0; i--) {
				stack.push({ node: currentNode.childNodes[i], depth: depth + 1 })
			}
			if (includeShadowRoot && currentNode.shadowRoot) {
				stack.push({ node: currentNode.shadowRoot, depth: depth + 1 })
			}
		}
		return nodes.sort((a, b) => a.depth - b.depth).map((item) => item.node)
	}

	function subtreeMatching(element, conditions = []) {
		conditions = Array.isArray(conditions) ? conditions : [conditions]
		conditions = [(node) => node.constructor.name != 'HTMLDocument', ...conditions]
		return flatNodesOf(element)
			.reverse()
			.filter((node) => all(conditions, node))
	}

	function getBoundingClientRectWithShadowRoot(node) {

		const nodes = flatNodesOf(node, { maxDepth: MAX_SHADOW_ROOT_CRAWL_DEPTH })
		const parentRect = {
			top: Number.POSITIVE_INFINITY, left: Number.POSITIVE_INFINITY,
			right: Number.NEGATIVE_INFINITY, bottom: Number.NEGATIVE_INFINITY,
			width: 0, height: 0
		}

		for (let node of nodes) {
			const childRect = node?.getBoundingClientRect && node.getBoundingClientRect()
			if (!childRect || (childRect.bottom === childRect.top && childRect.left === childRect.right)) {
				continue
			}
			if (childRect?.top < parentRect.top) { parentRect.top = childRect.top }
			if (childRect?.left < parentRect.left) { parentRect.left = childRect.left }
			if (childRect?.right > parentRect.right) { parentRect.right = childRect.right }
			if (childRect?.bottom > parentRect.bottom) { parentRect.bottom = childRect.bottom }
		}
		parentRect.width = parentRect.right - parentRect.left
		parentRect.height = parentRect.bottom - parentRect.top

		return parentRect
	}

	function methodA(node) {
		const banlist = [
			/iubenda/i,
			/cookie-banner/i,
			/cookie-popup/i,
			/cookie-consent/i,
			/gdpr/i,

			/^didomi-popup$/i,
			/^notice-cookie-block$/i,
			/^tc-privacy-wrapper$/i,
			/^cmpbox$/i,
			/^cookie-note$/i,
			/^cookie-law-info-bar$/i,
			/^cf-root$/i,
			/^cookiefirst-root$/i,
			/^CybotCookiebotDialog$/i,
			/^usercentrics-root$/i,
			/^onetrust-consent-sdk$/i,
			/^onetrust-banner-sdk$/i,
			/^ppms_cm_popup_overlay$/i,
			/^consent_blackbar$/i,
			/^truste-consent-track$/i,
			/^consent-bump$/i,
			/^sp_message_container$/i,
			/^sp_message_container_.*$/i,
			/^consentBanner$/i,
			/^cookie-banner-root$/i,
			/^gdpr-banner-container$/i,
			/^as-oil$/i,
			/^iubenda$/i,
			/^iubenda-cs-banner$/i,
			/^gdpr$/i,
			/^cookies$/i,
			/^cookie$/i,
			/^privacy-policy$/i,
			/^privacyPolicy$/i,
			/^tracking$/i,
			/^privacy$/i,
			/^consent$/i,
			/^qc-cmp[0-9]?-container$/i,
			/^qc-cmp[0-9]?-ui-container$/i,
			/^qc-cmp[0-9]?-showing$/i,
			/^wt-cli-cookie-bar-container$/i,
			/^wt-cli-cookie-bar$/i,
			/^BorlabsCookie$/i,
			/^osano-cm-window$/i,
			/^js-cookie-consent-banner$/i,
			/^hx_cookie-banner$/i,
			/^ytd-consent-bump-v2-lightbox$/i,
		]
		const classes = [...(node?.classList || [])]
		const id = node.id.toLowerCase()
		const haystack = [id].concat(classes)
		const conditions = banlist.map((ban) => (hay) => hay.match(ban))
		const matches = undefined !== haystack.find((hay) => any(conditions, hay))
		return matches
	}

	function methodB(node) {

		if (!hasPlausibleSize(node)) {
			return false
		}

		const confirmationHints = [/\ba(cc|kz)e(p)?t/i, /\bagree\b/i, /\bconsent\b/i, /\bcontinue\b/i, /\benable\b/i, /\ballow\b/i, /\bok\b/i]
		const denialHints = [
			/\breject\b/i,
			/\brifiuta\b/i,
			/\bdeny\b/i,
			/\bclose\b/i,
			/\boptions\b/i,
			/\bcookie\bpreferences\b/i,
			/\bcookie\bsettings\b/i,
			/\b(non)?(-)?essen(t|z)ial\b/i,
			/\b(ab)?lehnen?/i,
		]
		const contentHints = [/\bcookie(s)?\b/i, /(we|this site) use(s)?/i]

		const clickableElements = subtreeMatching(
			node,
			[
				(node) => (
					node?.tagName?.match(/^a$/i) ||
					node?.tagName?.match(/button/i) ||
					node?.classList?.toString().match(/(button|btn|clickable)/i) ||
					(node?.tagName?.match(/^input$/i) && node?.attributes?.type?.value?.match(/^submit$/i))
				)
			]
		)

		const controlElementRequirements = [
			(node) => typeof node?.innerText === 'string' && typeof node?.innerText?.match === 'function',
			(node) => !node?.classList?.toString().match(/(auth|login|log-in|signin|sign-in|signup|sign-up|register|registration)/i),
			(node) => node?.innerText?.length <= 32,
			(node) => !node?.innerText.match(/(auth|login|log in|signin|sign in|signup|sign up|register|registration)/i),
		]

		const controlElements = clickableElements.filter(el => all(controlElementRequirements, el))

		const acceptConditions = confirmationHints.map((h) => (t) => t.match(h))
		const has1Accept = controlElements.find((el) => any(acceptConditions, el.innerText))

		const denyConditions = denialHints.map((h) => (t) => t.match(h))
		const has1Deny = controlElements.find((el) => any(denyConditions, el.innerText))

		const contentConditions = contentHints.map((h) => (t) => t.match(h))
		const haystackText = node?.innerText || ''
		const hasMatchingContent = any(contentConditions, haystackText)

		return !!(node && clickableElements.length >= 2 && (has1Accept || has1Deny) && hasMatchingContent)
	}

	const whitelist = ['html', 'body', 'main', 'article']
	const detectionMethods = [methodA, methodB]

	function isNotMain(node) {
		const qs = whitelist.join(', ')
		const found = node.querySelector(qs)
		const tagName = node?.tagName?.toLowerCase() || ''
		return !whitelist.includes(tagName) && node.role != 'main' && !found
	}

	function isNotWhitelisted(node) {
		const tag = node?.tagName?.toLowerCase()
		return !!tag && !whitelist.includes(tag)
	}

	function hasPlausibleSize(node) {
		const va = window.innerHeight * window.innerWidth
		const nr = node?.getBoundingClientRect && getBoundingClientRectWithShadowRoot(node)
		const na = nr && nr.width * nr.height
		const rat1 = na && Math.sqrt(na) / Math.sqrt(va)
		const rat2 = nr && nr.width / nr.height
		return rat1 > 0.05 && rat1 < 1.1 && rat2 > 0.9 && nr.height < window.innerHeight
	}

	function isInViewport(node) {
		const style = node?.constructor?.name?.match(/Element$/) && window.getComputedStyle(node)
		const isFixed = !!style && style.position === 'fixed'
		const fromY = !isFixed ? window.scrollY : 0
		const toY = !isFixed ? fromY + window.innerHeight : window.innerHeight
		const bb = node?.getBoundingClientRect && getBoundingClientRectWithShadowRoot(node)
		return bb && ((bb.top >= fromY && bb.top <= toY) || (bb.bottom >= fromY && bb.bottom <= toY))
	}

	function containsClickableSomething(node) {
		return (
			typeof node?.querySelector === 'function' &&
			(node.querySelector('button, a, input[type="submit"]') ||
				subtreeMatching(node, (node) => node?.classList?.toString().match(/(button|btn|clickable)/i)).length)
		)
	}

	function doesntContainEditableInputFields(node) {
		const inputTypes = [
			'color',
			'date',
			'datetime-local',
			'email',
			'file',
			'image',
			'month',
			'number',
			'password',
			'range',
			'search',
			'tel',
			'text',
			'time',
			'url',
			'week',
		]
		const qs = inputTypes.map((t) => `input[type="${t}"]`).join(', ')
		const found = node.querySelector(qs)
		return !found
	}

	function isOverlay(node) {
		const style = window.getComputedStyle(node)
		const viewport = window.innerHeight * window.innerWidth
		const rect = node?.getBoundingClientRect && node.getBoundingClientRect(node)
		const area = node && rect.width * rect.height
		const rat = area / viewport
		return (
			rat >= 0.8 &&
			(style.height === '100%' ||
				style.height === '100vh' ||
				style.position === 'fixed' ||
				style.position === 'absolute' ||
				style.zIndex >= 1000 ||
				style.opacity < 0.99)
		)
	}

	function getGdprDialogs() {
		return subtreeMatching(document, [
			(node) => typeof node?.querySelectorAll === 'function',
			(node) => isNotMain(node),
			(node) => isNotWhitelisted(node),
			(node) => containsClickableSomething(node),
			(node) => doesntContainEditableInputFields(node),
			(node) => isInViewport(node),
			(node) => any(detectionMethods, node),
		]).sort((a, b) => {
			const ar = a.getBoundingClientRect()
			const br = b.getBoundingClientRect()
			const aa = ar.height * ar.width
			const ba = br.height * br.width
			return ba - aa
		})
	}

	function closeGdprDialogs(limit = 1) {
		return getGdprDialogs()
			.filter(node => !!node.remove)
			.slice(0, limit)
			.map((node) => {
				console.info('[GDPR BLASTER] Removing dialog:', node)
				node.remove()
				return node
			})
	}

	function restoreScroll() {

		// Many websites disable scrolling when the gdpr dialog is open
		// Since we harshly remove()'d the dialogs, we need to do our best to ensure user can scroll page content

		const elements = [
			document.body,
			document.querySelector('html'),
			document.querySelector('main') || document.querySelector('#main'),
			document.documentElement,
			document.scrollingElement,
		].filter((x) => !!x)

		const needles = [/no[-_]?scroll/i, /scroll(ing)?[-_]?disabl/i, /disabl.*scroll/i, /(modal|popup|banner|gdpr|consent)[-_]?open/i]

		for (const element of elements) {
			const classes = [...element.classList]
			for (const cls of classes) {
				for (const needle of needles) {
					if (cls.match(needle)) {
						element.classList.remove(cls)
					}
				}
			}
			element.style.overflow = 'scroll'
			element.style.position = ''
		}
	}

	function removeOverlays(limit = 1) {
		return flatNodesOf(document, { maxDepth: MAX_OVERLAY_REMOVAL_DEPTH })
			.filter((node) => {
				return (
					typeof node?.querySelector === 'function' &&
					isNotWhitelisted(node) &&
					isOverlay(node) &&
					isNotMain(node) &&
					doesntContainEditableInputFields(node)
				)
			})
			.filter(node => !!node.remove)
			.slice(0, limit)
			.map((node) => {
				console.info('[GDPR BLASTER] Removing overlay:', node)
				node.remove()
				return node
			})
	}

	function getDialogsLeftToClose() {
		return Math.max(0, MAX_DIALOG_REMOVALS - dialogsClosed)
	}

	function getOverlaysLeftToClose() {
		return Math.max(0, MAX_OVERLAY_REMOVALS - overlaysClosed)
	}

	function check() {
		const dltc = Math.min(getDialogsLeftToClose(), MAX_DIALOG_REMOVALS_PER_PASS)
		const oltc = Math.min(getOverlaysLeftToClose(), MAX_OVERLAY_REMOVALS_PER_PASS)
		let once = 1
		if (dltc > 0) {
			const closed = closeGdprDialogs(dltc)
			if (closed.length) {
				triggeredCount += once
				once = 0
			}
		}
		if (oltc > 0) {
			const closed = removeOverlays(oltc)
			overlaysClosed += closed.length
			if (closed.length) {
				triggeredCount += once
				once = 0
			}
		}
		if (once === 0) {
			document.querySelector('html').style.overflow = ''
			document.querySelector('html').classList.remove('sp-message-open')
			restoreScroll()
		}
	}

	function enqueue(i = 0, ms = 0) {
		const before = Date.now()
		setTimeout(() => {
			check()
			if (
				triggeredCount <= MAX_TRIGGERS
				&& i < MAX_ATTEMPTS
				&& (getDialogsLeftToClose() || getOverlaysLeftToClose())
			) {
				const elapsed = Date.now() - before
				const ms = Math.max(MIN_CHECK_DELAY_MS, Math.min(MAX_CHECK_DELAY_MS, MIN_CHECK_DELAY_MS + ((1.337 ** (i + 23)) / 42) - elapsed))
				enqueue(i + 1, ms)
			}
		}, ms)
	}

	oncePageLoaded(enqueue)
})()