From 1c4ea1499545b57dc422137d71373b10ea9d323a Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:22:51 -0700 Subject: [PATCH 01/26] fix: enforce first-party popup cap for ads --- composables/useAdvertisements.ts | 147 +++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index 17065965..06cb47dc 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -1,8 +1,155 @@ import { default as randomWeightedChoice } from 'random-weighted-choice' +const AD_POPUP_CAP_DURATION_MS = 30 * 60 * 1000 +const AD_LAST_POPUP_AT_STORAGE_KEY = 'ads-last-popup-at' +const STACK_URL_REGEX = /https?:\/\/[^\s)]+/g + +type WindowOpenArgs = Parameters +type WindowOpenResult = ReturnType + +function getScriptUrlsFromStack(stack: string): URL[] { + const matches = stack.match(STACK_URL_REGEX) + + if (!matches) { + return [] + } + + const urls: URL[] = [] + + for (const rawMatch of matches) { + const normalizedUrl = rawMatch + .replace(/[),]$/, '') + .replace(/:\d+:\d+$/, '') + + try { + urls.push(new URL(normalizedUrl)) + } catch { + // Ignore malformed URLs from stack traces + } + } + + return urls +} + +function isLikelyVendorPopupCall(stack: string | undefined, hasUserActivation: boolean): boolean { + if (!stack) { + return !hasUserActivation + } + + const callerScriptUrls = getScriptUrlsFromStack(stack) + + if (callerScriptUrls.length === 0) { + return !hasUserActivation + } + + const currentOrigin = window.location.origin + + for (const scriptUrl of callerScriptUrls) { + if (scriptUrl.origin !== currentOrigin) { + return true + } + + // Keep the heuristic broad: treat same-origin static /js scripts as likely ad/vendor callers. + if (scriptUrl.pathname.startsWith('/js/')) { + return true + } + } + + return !hasUserActivation +} + export default function () { const popunderScript = useState('popunder-script', () => '') const pushScript = useState('push-notification-script', () => '') + const isPopupGuardInstalled = useState('ads-popup-guard-installed', () => false) + const isPopupGuardArmed = useState('ads-popup-guard-armed', () => false) + const lastAdPopupAtInMemory = useState('ads-last-popup-at-in-memory', () => null) + + if (!import.meta.client) { + return + } + + function getLastAdPopupAt(): number | null { + if (lastAdPopupAtInMemory.value !== null) { + return lastAdPopupAtInMemory.value + } + + try { + const rawLastPopupAt = window.localStorage.getItem(AD_LAST_POPUP_AT_STORAGE_KEY) + + if (rawLastPopupAt) { + const parsedLastPopupAt = Number.parseInt(rawLastPopupAt, 10) + + if (Number.isFinite(parsedLastPopupAt) && parsedLastPopupAt > 0) { + lastAdPopupAtInMemory.value = parsedLastPopupAt + return parsedLastPopupAt + } + } + } catch { + // Ignore storage failures and use in-memory fallback + } + + return null + } + + function recordAdPopupOpened(at = Date.now()) { + lastAdPopupAtInMemory.value = at + + try { + window.localStorage.setItem(AD_LAST_POPUP_AT_STORAGE_KEY, String(at)) + } catch { + // Ignore storage failures and keep the in-memory fallback + } + } + + function isAdPopupCapActive(): boolean { + const lastAdPopupAt = getLastAdPopupAt() + + if (!lastAdPopupAt) { + return false + } + + return Date.now() - lastAdPopupAt < AD_POPUP_CAP_DURATION_MS + } + + if (!isPopupGuardInstalled.value) { + const originalWindowOpen = window.open.bind(window) + + window.open = (...args: WindowOpenArgs): WindowOpenResult => { + if (!isPopupGuardArmed.value) { + return originalWindowOpen(...args) + } + + const userActivation = (window.navigator as Navigator & { + userActivation?: { isActive: boolean } + }).userActivation + + const hasUserActivation = userActivation?.isActive ?? true + const stack = new Error().stack + + if (!isLikelyVendorPopupCall(stack, hasUserActivation)) { + return originalWindowOpen(...args) + } + + if (isAdPopupCapActive()) { + return null + } + + recordAdPopupOpened() + + return originalWindowOpen(...args) + } + + isPopupGuardInstalled.value = true + } + + // Phase 1: stop injecting ad scripts while the 30-minute popup cap is active. + if (isAdPopupCapActive()) { + return + } + + // Phase 2 (arming): once scripts load, guard vendor-like popups with the first-party cap. + isPopupGuardArmed.value = true const popunderAds = [ /** From a83dc8286d11d971b8f070d210110cb1628c5d46 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:33:05 -0700 Subject: [PATCH 02/26] refactor: add popup guard debug instrumentation --- composables/useAdvertisements.ts | 127 +++++++++++++++++++++++++++---- 1 file changed, 112 insertions(+), 15 deletions(-) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index 06cb47dc..9fdf5831 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -2,12 +2,27 @@ import { default as randomWeightedChoice } from 'random-weighted-choice' const AD_POPUP_CAP_DURATION_MS = 30 * 60 * 1000 const AD_LAST_POPUP_AT_STORAGE_KEY = 'ads-last-popup-at' +// Temporary debug toggles for popup-cap behavior: +// - window.__ADS_POPUP_GUARD_DEBUG__ = true +// - localStorage.setItem('ads-popup-guard-debug', '1') +const AD_POPUP_DEBUG_STORAGE_KEY = 'ads-popup-guard-debug' +const AD_POPUP_DEBUG_WINDOW_FLAG = '__ADS_POPUP_GUARD_DEBUG__' const STACK_URL_REGEX = /https?:\/\/[^\s)]+/g +const DEBUG_TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']) type WindowOpenArgs = Parameters type WindowOpenResult = ReturnType +type PopupCapState = { + isActive: boolean + lastPopupAt: number | null + elapsedSinceLastPopupMs: number | null +} + +function getScriptUrlsFromStack(stack?: string): URL[] { + if (!stack) { + return [] + } -function getScriptUrlsFromStack(stack: string): URL[] { const matches = stack.match(STACK_URL_REGEX) if (!matches) { @@ -31,13 +46,7 @@ function getScriptUrlsFromStack(stack: string): URL[] { return urls } -function isLikelyVendorPopupCall(stack: string | undefined, hasUserActivation: boolean): boolean { - if (!stack) { - return !hasUserActivation - } - - const callerScriptUrls = getScriptUrlsFromStack(stack) - +function isLikelyVendorPopupCall(callerScriptUrls: URL[], hasUserActivation: boolean): boolean { if (callerScriptUrls.length === 0) { return !hasUserActivation } @@ -69,6 +78,58 @@ export default function () { return } + function isPopupGuardDebugEnabled(): boolean { + const debugFlagOnWindow = (window as Window & Record)[AD_POPUP_DEBUG_WINDOW_FLAG] + + if (typeof debugFlagOnWindow === 'boolean') { + return debugFlagOnWindow + } + + try { + const rawDebugFlag = window.localStorage.getItem(AD_POPUP_DEBUG_STORAGE_KEY) + + if (!rawDebugFlag) { + return false + } + + return DEBUG_TRUTHY_VALUES.has(rawDebugFlag.trim().toLowerCase()) + } catch { + return false + } + } + + function debugPopupGuardDecision(details: { + decision: 'allowed' | 'blocked' + reason: 'vendor-cap-active' | 'vendor-cap-inactive' + args: WindowOpenArgs + hasUserActivation: boolean + callerScriptUrls: URL[] + capState: PopupCapState + recordedPopupAt?: number + }) { + if (!isPopupGuardDebugEnabled()) { + return + } + + const [requestedUrl, target, windowFeatures] = details.args + + console.debug('[ads-popup-guard]', { + decision: details.decision, + reason: details.reason, + requestedUrl: typeof requestedUrl === 'string' ? requestedUrl : null, + target: typeof target === 'string' ? target : null, + windowFeatures: typeof windowFeatures === 'string' ? windowFeatures : null, + hasUserActivation: details.hasUserActivation, + callerScriptUrlCount: details.callerScriptUrls.length, + callerScriptUrls: details.callerScriptUrls.slice(0, 5).map(scriptUrl => scriptUrl.href), + capDurationMs: AD_POPUP_CAP_DURATION_MS, + capActive: details.capState.isActive, + lastPopupAt: details.capState.lastPopupAt, + elapsedSinceLastPopupMs: details.capState.elapsedSinceLastPopupMs, + recordedPopupAt: details.recordedPopupAt ?? null + }) + } + function getLastAdPopupAt(): number | null { if (lastAdPopupAtInMemory.value !== null) { return lastAdPopupAtInMemory.value @@ -102,14 +163,28 @@ export default function () { } } - function isAdPopupCapActive(): boolean { + function getAdPopupCapState(now = Date.now()): PopupCapState { const lastAdPopupAt = getLastAdPopupAt() if (!lastAdPopupAt) { - return false + return { + isActive: false, + lastPopupAt: null, + elapsedSinceLastPopupMs: null + } } - return Date.now() - lastAdPopupAt < AD_POPUP_CAP_DURATION_MS + const elapsedSinceLastPopupMs = now - lastAdPopupAt + + return { + isActive: elapsedSinceLastPopupMs < AD_POPUP_CAP_DURATION_MS, + lastPopupAt: lastAdPopupAt, + elapsedSinceLastPopupMs + } + } + + function isAdPopupCapActive(): boolean { + return getAdPopupCapState().isActive } if (!isPopupGuardInstalled.value) { @@ -125,17 +200,39 @@ export default function () { }).userActivation const hasUserActivation = userActivation?.isActive ?? true - const stack = new Error().stack + const callerScriptUrls = getScriptUrlsFromStack(new Error().stack) - if (!isLikelyVendorPopupCall(stack, hasUserActivation)) { + if (!isLikelyVendorPopupCall(callerScriptUrls, hasUserActivation)) { return originalWindowOpen(...args) } - if (isAdPopupCapActive()) { + const capState = getAdPopupCapState() + + if (capState.isActive) { + debugPopupGuardDecision({ + decision: 'blocked', + reason: 'vendor-cap-active', + args, + hasUserActivation, + callerScriptUrls, + capState + }) + return null } - recordAdPopupOpened() + const openedAt = Date.now() + recordAdPopupOpened(openedAt) + + debugPopupGuardDecision({ + decision: 'allowed', + reason: 'vendor-cap-inactive', + args, + hasUserActivation, + callerScriptUrls, + capState, + recordedPopupAt: openedAt + }) return originalWindowOpen(...args) } From 6734b52d4df39aa1ae9d30dc26c8502b22e389ae Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:37:17 -0700 Subject: [PATCH 03/26] refactor: expose popup guard match reasons --- composables/useAdvertisements.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index 9fdf5831..77c3be31 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -17,6 +17,7 @@ type PopupCapState = { lastPopupAt: number | null elapsedSinceLastPopupMs: number | null } +type VendorPopupMatchReason = 'cross-origin-script' | 'same-origin-js-script' | 'no-user-activation' function getScriptUrlsFromStack(stack?: string): URL[] { if (!stack) { @@ -46,25 +47,28 @@ function getScriptUrlsFromStack(stack?: string): URL[] { return urls } -function isLikelyVendorPopupCall(callerScriptUrls: URL[], hasUserActivation: boolean): boolean { +function getVendorPopupMatchReason( + callerScriptUrls: URL[], + hasUserActivation: boolean +): VendorPopupMatchReason | null { if (callerScriptUrls.length === 0) { - return !hasUserActivation + return hasUserActivation ? null : 'no-user-activation' } const currentOrigin = window.location.origin for (const scriptUrl of callerScriptUrls) { if (scriptUrl.origin !== currentOrigin) { - return true + return 'cross-origin-script' } // Keep the heuristic broad: treat same-origin static /js scripts as likely ad/vendor callers. if (scriptUrl.pathname.startsWith('/js/')) { - return true + return 'same-origin-js-script' } } - return !hasUserActivation + return hasUserActivation ? null : 'no-user-activation' } export default function () { @@ -101,6 +105,7 @@ export default function () { function debugPopupGuardDecision(details: { decision: 'allowed' | 'blocked' reason: 'vendor-cap-active' | 'vendor-cap-inactive' + vendorPopupMatchReason: VendorPopupMatchReason args: WindowOpenArgs hasUserActivation: boolean callerScriptUrls: URL[] @@ -116,6 +121,7 @@ export default function () { console.debug('[ads-popup-guard]', { decision: details.decision, reason: details.reason, + vendorPopupMatchReason: details.vendorPopupMatchReason, requestedUrl: typeof requestedUrl === 'string' ? requestedUrl : null, target: typeof target === 'string' ? target : null, windowFeatures: typeof windowFeatures === 'string' ? windowFeatures : null, @@ -196,13 +202,17 @@ export default function () { } const userActivation = (window.navigator as Navigator & { + // Legacy browsers may not expose navigator.userActivation. userActivation?: { isActive: boolean } }).userActivation + // Default to true when userActivation is unavailable so older browsers keep allowing + // popups instead of breaking ad flows entirely; this trades stricter detection for compatibility. const hasUserActivation = userActivation?.isActive ?? true const callerScriptUrls = getScriptUrlsFromStack(new Error().stack) + const vendorPopupMatchReason = getVendorPopupMatchReason(callerScriptUrls, hasUserActivation) - if (!isLikelyVendorPopupCall(callerScriptUrls, hasUserActivation)) { + if (!vendorPopupMatchReason) { return originalWindowOpen(...args) } @@ -212,6 +222,7 @@ export default function () { debugPopupGuardDecision({ decision: 'blocked', reason: 'vendor-cap-active', + vendorPopupMatchReason, args, hasUserActivation, callerScriptUrls, @@ -227,6 +238,7 @@ export default function () { debugPopupGuardDecision({ decision: 'allowed', reason: 'vendor-cap-inactive', + vendorPopupMatchReason, args, hasUserActivation, callerScriptUrls, From 6c9ec6ebc1cc94e8d48d1f12a4e5458ab805f764 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:36:09 -0700 Subject: [PATCH 04/26] fix: harden popup cap state handling --- composables/useAdvertisements.ts | 112 +++++++++++++++++++++++++------ 1 file changed, 90 insertions(+), 22 deletions(-) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index 77c3be31..bbf7bc1c 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -9,6 +9,7 @@ const AD_POPUP_DEBUG_STORAGE_KEY = 'ads-popup-guard-debug' const AD_POPUP_DEBUG_WINDOW_FLAG = '__ADS_POPUP_GUARD_DEBUG__' const STACK_URL_REGEX = /https?:\/\/[^\s)]+/g const DEBUG_TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']) +const INTEGER_TIMESTAMP_REGEX = /^\d+$/ type WindowOpenArgs = Parameters type WindowOpenResult = ReturnType @@ -111,6 +112,8 @@ export default function () { callerScriptUrls: URL[] capState: PopupCapState recordedPopupAt?: number + openAttemptOutcome?: 'opened' | 'blocked-or-null' | 'threw-error' + openError?: string }) { if (!isPopupGuardDebugEnabled()) { return @@ -132,22 +135,55 @@ export default function () { capActive: details.capState.isActive, lastPopupAt: details.capState.lastPopupAt, elapsedSinceLastPopupMs: details.capState.elapsedSinceLastPopupMs, - recordedPopupAt: details.recordedPopupAt ?? null + recordedPopupAt: details.recordedPopupAt ?? null, + openAttemptOutcome: details.openAttemptOutcome ?? null, + openError: details.openError ?? null }) } - function getLastAdPopupAt(): number | null { - if (lastAdPopupAtInMemory.value !== null) { - return lastAdPopupAtInMemory.value + function parseStoredLastPopupAt(rawLastPopupAt: string, now: number): number | null { + const normalizedRawLastPopupAt = rawLastPopupAt.trim() + + if (!INTEGER_TIMESTAMP_REGEX.test(normalizedRawLastPopupAt)) { + return null + } + + const parsedLastPopupAt = Number(normalizedRawLastPopupAt) + + if ( + !Number.isSafeInteger(parsedLastPopupAt) + || parsedLastPopupAt <= 0 + || parsedLastPopupAt > now + ) { + return null + } + + return parsedLastPopupAt + } + + function getLastAdPopupAt(now = Date.now()): number | null { + const inMemoryLastPopupAt = lastAdPopupAtInMemory.value + + if (inMemoryLastPopupAt !== null) { + if ( + Number.isSafeInteger(inMemoryLastPopupAt) + && inMemoryLastPopupAt > 0 + && inMemoryLastPopupAt <= now + ) { + return inMemoryLastPopupAt + } + + // Reset invalid or future in-memory values so they do not over-block. + lastAdPopupAtInMemory.value = null } try { const rawLastPopupAt = window.localStorage.getItem(AD_LAST_POPUP_AT_STORAGE_KEY) if (rawLastPopupAt) { - const parsedLastPopupAt = Number.parseInt(rawLastPopupAt, 10) + const parsedLastPopupAt = parseStoredLastPopupAt(rawLastPopupAt, now) - if (Number.isFinite(parsedLastPopupAt) && parsedLastPopupAt > 0) { + if (parsedLastPopupAt !== null) { lastAdPopupAtInMemory.value = parsedLastPopupAt return parsedLastPopupAt } @@ -170,7 +206,7 @@ export default function () { } function getAdPopupCapState(now = Date.now()): PopupCapState { - const lastAdPopupAt = getLastAdPopupAt() + const lastAdPopupAt = getLastAdPopupAt(now) if (!lastAdPopupAt) { return { @@ -232,21 +268,53 @@ export default function () { return null } - const openedAt = Date.now() - recordAdPopupOpened(openedAt) - - debugPopupGuardDecision({ - decision: 'allowed', - reason: 'vendor-cap-inactive', - vendorPopupMatchReason, - args, - hasUserActivation, - callerScriptUrls, - capState, - recordedPopupAt: openedAt - }) - - return originalWindowOpen(...args) + try { + const popupHandle = originalWindowOpen(...args) + + if (popupHandle) { + const openedAt = Date.now() + recordAdPopupOpened(openedAt) + + debugPopupGuardDecision({ + decision: 'allowed', + reason: 'vendor-cap-inactive', + vendorPopupMatchReason, + args, + hasUserActivation, + callerScriptUrls, + capState, + recordedPopupAt: openedAt, + openAttemptOutcome: 'opened' + }) + } else { + debugPopupGuardDecision({ + decision: 'allowed', + reason: 'vendor-cap-inactive', + vendorPopupMatchReason, + args, + hasUserActivation, + callerScriptUrls, + capState, + openAttemptOutcome: 'blocked-or-null' + }) + } + + return popupHandle + } catch (error) { + debugPopupGuardDecision({ + decision: 'allowed', + reason: 'vendor-cap-inactive', + vendorPopupMatchReason, + args, + hasUserActivation, + callerScriptUrls, + capState, + openAttemptOutcome: 'threw-error', + openError: error instanceof Error ? error.message : String(error) + }) + + throw error + } } isPopupGuardInstalled.value = true From 8c1dfa8c60ac207fb538c0ff1ea6e0bc78f34f0a Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:02:22 -0700 Subject: [PATCH 05/26] fix: prefer latest popup timestamp across tabs --- composables/useAdvertisements.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index bbf7bc1c..cacac9ee 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -162,6 +162,7 @@ export default function () { } function getLastAdPopupAt(now = Date.now()): number | null { + let resolvedLastPopupAt: number | null = null const inMemoryLastPopupAt = lastAdPopupAtInMemory.value if (inMemoryLastPopupAt !== null) { @@ -170,11 +171,11 @@ export default function () { && inMemoryLastPopupAt > 0 && inMemoryLastPopupAt <= now ) { - return inMemoryLastPopupAt + resolvedLastPopupAt = inMemoryLastPopupAt + } else { + // Reset invalid or future in-memory values so they do not over-block. + lastAdPopupAtInMemory.value = null } - - // Reset invalid or future in-memory values so they do not over-block. - lastAdPopupAtInMemory.value = null } try { @@ -183,16 +184,20 @@ export default function () { if (rawLastPopupAt) { const parsedLastPopupAt = parseStoredLastPopupAt(rawLastPopupAt, now) - if (parsedLastPopupAt !== null) { - lastAdPopupAtInMemory.value = parsedLastPopupAt - return parsedLastPopupAt + if ( + parsedLastPopupAt !== null + && (resolvedLastPopupAt === null || parsedLastPopupAt > resolvedLastPopupAt) + ) { + resolvedLastPopupAt = parsedLastPopupAt } } } catch { // Ignore storage failures and use in-memory fallback } - return null + lastAdPopupAtInMemory.value = resolvedLastPopupAt + + return resolvedLastPopupAt } function recordAdPopupOpened(at = Date.now()) { From 2e02ceb6ad6ee57b4976045839fe75222a4b1098 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:19:21 -0700 Subject: [PATCH 06/26] fix: spend popup cap on first vendor attempt --- composables/useAdvertisements.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index cacac9ee..d4c29d7d 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -114,6 +114,7 @@ export default function () { recordedPopupAt?: number openAttemptOutcome?: 'opened' | 'blocked-or-null' | 'threw-error' openError?: string + capRecordedAt?: number }) { if (!isPopupGuardDebugEnabled()) { return @@ -136,6 +137,7 @@ export default function () { lastPopupAt: details.capState.lastPopupAt, elapsedSinceLastPopupMs: details.capState.elapsedSinceLastPopupMs, recordedPopupAt: details.recordedPopupAt ?? null, + capRecordedAt: details.capRecordedAt ?? null, openAttemptOutcome: details.openAttemptOutcome ?? null, openError: details.openError ?? null }) @@ -273,13 +275,13 @@ export default function () { return null } + const attemptedAt = Date.now() + recordAdPopupOpened(attemptedAt) + try { const popupHandle = originalWindowOpen(...args) if (popupHandle) { - const openedAt = Date.now() - recordAdPopupOpened(openedAt) - debugPopupGuardDecision({ decision: 'allowed', reason: 'vendor-cap-inactive', @@ -288,7 +290,8 @@ export default function () { hasUserActivation, callerScriptUrls, capState, - recordedPopupAt: openedAt, + recordedPopupAt: attemptedAt, + capRecordedAt: attemptedAt, openAttemptOutcome: 'opened' }) } else { @@ -300,6 +303,7 @@ export default function () { hasUserActivation, callerScriptUrls, capState, + capRecordedAt: attemptedAt, openAttemptOutcome: 'blocked-or-null' }) } @@ -314,6 +318,7 @@ export default function () { hasUserActivation, callerScriptUrls, capState, + capRecordedAt: attemptedAt, openAttemptOutcome: 'threw-error', openError: error instanceof Error ? error.message : String(error) }) From 91eba7f5bacbcc98256e5196b60ec20fcbcc2fc0 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:27:20 -0700 Subject: [PATCH 07/26] fix: exempt in-page push opens from popup cap --- composables/useAdvertisements.ts | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index d4c29d7d..35f99e18 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -19,6 +19,7 @@ type PopupCapState = { elapsedSinceLastPopupMs: number | null } type VendorPopupMatchReason = 'cross-origin-script' | 'same-origin-js-script' | 'no-user-activation' +type VendorOpenKind = 'popunder' | 'in-page-push' function getScriptUrlsFromStack(stack?: string): URL[] { if (!stack) { @@ -72,6 +73,34 @@ function getVendorPopupMatchReason( return hasUserActivation ? null : 'no-user-activation' } +function getRequestedUrl(args: WindowOpenArgs): string | null { + const [requestedUrl] = args + + return typeof requestedUrl === 'string' ? requestedUrl : null +} + +function getVendorOpenKind(args: WindowOpenArgs): VendorOpenKind { + const requestedUrl = getRequestedUrl(args) + + if (!requestedUrl) { + return 'popunder' + } + + try { + const parsedUrl = new URL(requestedUrl, window.location.href) + + for (const searchParamKey of parsedUrl.searchParams.keys()) { + if (searchParamKey.startsWith('inpage.')) { + return 'in-page-push' + } + } + } catch { + // Ignore malformed vendor URLs and keep the default popunder classification. + } + + return 'popunder' +} + export default function () { const popunderScript = useState('popunder-script', () => '') const pushScript = useState('push-notification-script', () => '') @@ -107,6 +136,7 @@ export default function () { decision: 'allowed' | 'blocked' reason: 'vendor-cap-active' | 'vendor-cap-inactive' vendorPopupMatchReason: VendorPopupMatchReason + vendorOpenKind: VendorOpenKind args: WindowOpenArgs hasUserActivation: boolean callerScriptUrls: URL[] @@ -126,6 +156,7 @@ export default function () { decision: details.decision, reason: details.reason, vendorPopupMatchReason: details.vendorPopupMatchReason, + vendorOpenKind: details.vendorOpenKind, requestedUrl: typeof requestedUrl === 'string' ? requestedUrl : null, target: typeof target === 'string' ? target : null, windowFeatures: typeof windowFeatures === 'string' ? windowFeatures : null, @@ -254,11 +285,27 @@ export default function () { const hasUserActivation = userActivation?.isActive ?? true const callerScriptUrls = getScriptUrlsFromStack(new Error().stack) const vendorPopupMatchReason = getVendorPopupMatchReason(callerScriptUrls, hasUserActivation) + const vendorOpenKind = getVendorOpenKind(args) if (!vendorPopupMatchReason) { return originalWindowOpen(...args) } + if (vendorOpenKind === 'in-page-push') { + debugPopupGuardDecision({ + decision: 'allowed', + reason: 'vendor-cap-inactive', + vendorPopupMatchReason, + vendorOpenKind, + args, + hasUserActivation, + callerScriptUrls, + capState: getAdPopupCapState() + }) + + return originalWindowOpen(...args) + } + const capState = getAdPopupCapState() if (capState.isActive) { @@ -266,6 +313,7 @@ export default function () { decision: 'blocked', reason: 'vendor-cap-active', vendorPopupMatchReason, + vendorOpenKind, args, hasUserActivation, callerScriptUrls, @@ -286,6 +334,7 @@ export default function () { decision: 'allowed', reason: 'vendor-cap-inactive', vendorPopupMatchReason, + vendorOpenKind, args, hasUserActivation, callerScriptUrls, @@ -299,6 +348,7 @@ export default function () { decision: 'allowed', reason: 'vendor-cap-inactive', vendorPopupMatchReason, + vendorOpenKind, args, hasUserActivation, callerScriptUrls, @@ -314,6 +364,7 @@ export default function () { decision: 'allowed', reason: 'vendor-cap-inactive', vendorPopupMatchReason, + vendorOpenKind, args, hasUserActivation, callerScriptUrls, From 85d891efff647f94c9da7dc3785ff7a3e7f59c0b Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:36:26 -0700 Subject: [PATCH 08/26] fix: defer ad arming until after first click --- pages/index.vue | 11 ++++++++++- pages/posts/[domain].vue | 11 ++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/pages/index.vue b/pages/index.vue index cfeb2801..d11b5541 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -122,6 +122,7 @@ */ onMounted(() => { const hasLoadedAds = ref(false) + let adLoadTimeoutId: number | null = null watch([hasInteracted, isPremium], ([hasInteracted, isPremium]) => { if (hasLoadedAds.value) { @@ -138,8 +139,16 @@ hasLoadedAds.value = true - useAdvertisements() + adLoadTimeoutId = window.setTimeout(() => { + useAdvertisements() + }, 0) }, { immediate: true }) + + onBeforeUnmount(() => { + if (adLoadTimeoutId !== null) { + window.clearTimeout(adLoadTimeoutId) + } + }) }) const featuredDomains = [ diff --git a/pages/posts/[domain].vue b/pages/posts/[domain].vue index ec982b6a..38f8d8a0 100644 --- a/pages/posts/[domain].vue +++ b/pages/posts/[domain].vue @@ -33,6 +33,7 @@ */ onMounted(() => { const hasLoadedAds = ref(false) + let adLoadTimeoutId: number | null = null watch([hasInteracted, isPremium], ([hasInteracted, isPremium]) => { if (hasLoadedAds.value) { @@ -49,8 +50,16 @@ hasLoadedAds.value = true - useAdvertisements() + adLoadTimeoutId = window.setTimeout(() => { + useAdvertisements() + }, 0) }, { immediate: true }) + + onBeforeUnmount(() => { + if (adLoadTimeoutId !== null) { + window.clearTimeout(adLoadTimeoutId) + } + }) }) /** From e37699d1d081b72b39638102dc94018e2dc1b3d7 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:55:48 -0700 Subject: [PATCH 09/26] fix: ignore capped vendor click handlers --- composables/useAdvertisements.ts | 97 ++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index 35f99e18..5964fe00 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -10,6 +10,13 @@ const AD_POPUP_DEBUG_WINDOW_FLAG = '__ADS_POPUP_GUARD_DEBUG__' const STACK_URL_REGEX = /https?:\/\/[^\s)]+/g const DEBUG_TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']) const INTEGER_TIMESTAMP_REGEX = /^\d+$/ +const VENDOR_INTERACTION_EVENT_TYPES = new Set([ + 'click', + 'mousedown', + 'mouseup', + 'pointerdown', + 'touchstart' +]) type WindowOpenArgs = Parameters type WindowOpenResult = ReturnType @@ -269,6 +276,96 @@ export default function () { if (!isPopupGuardInstalled.value) { const originalWindowOpen = window.open.bind(window) + const originalAddEventListener = EventTarget.prototype.addEventListener + const originalRemoveEventListener = EventTarget.prototype.removeEventListener + const wrappedVendorInteractionListeners = new WeakMap< + EventListenerOrEventListenerObject, + Map + >() + + EventTarget.prototype.addEventListener = function ( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions + ) { + if (!listener || !VENDOR_INTERACTION_EVENT_TYPES.has(type)) { + originalAddEventListener.call(this, type, listener, options) + + return + } + + const callerScriptUrls = getScriptUrlsFromStack(new Error().stack) + const vendorPopupMatchReason = getVendorPopupMatchReason(callerScriptUrls, true) + + if (!vendorPopupMatchReason || vendorPopupMatchReason === 'no-user-activation') { + originalAddEventListener.call(this, type, listener, options) + + return + } + + let wrappedListener = wrappedVendorInteractionListeners.get(listener)?.get(type) + + if (!wrappedListener) { + wrappedListener = function (this: EventTarget, event: Event) { + if (isPopupGuardArmed.value && isAdPopupCapActive()) { + if (isPopupGuardDebugEnabled()) { + const capState = getAdPopupCapState() + + console.debug('[ads-popup-guard]', { + decision: 'blocked', + reason: 'vendor-cap-active', + guard: 'interaction-listener', + eventType: event.type, + vendorPopupMatchReason, + callerScriptUrlCount: callerScriptUrls.length, + callerScriptUrls: callerScriptUrls.slice(0, 5).map(scriptUrl => scriptUrl.href), + capDurationMs: AD_POPUP_CAP_DURATION_MS, + capActive: capState.isActive, + lastPopupAt: capState.lastPopupAt, + elapsedSinceLastPopupMs: capState.elapsedSinceLastPopupMs + }) + } + + return + } + + if (typeof listener === 'function') { + listener.call(this, event) + + return + } + + listener.handleEvent.call(listener, event) + } + + let wrappedListenersByType = wrappedVendorInteractionListeners.get(listener) + + if (!wrappedListenersByType) { + wrappedListenersByType = new Map() + wrappedVendorInteractionListeners.set(listener, wrappedListenersByType) + } + + wrappedListenersByType.set(type, wrappedListener) + } + + originalAddEventListener.call(this, type, wrappedListener, options) + } + + EventTarget.prototype.removeEventListener = function ( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions + ) { + if (!listener || !VENDOR_INTERACTION_EVENT_TYPES.has(type)) { + originalRemoveEventListener.call(this, type, listener, options) + + return + } + + const wrappedListener = wrappedVendorInteractionListeners.get(listener)?.get(type) + + originalRemoveEventListener.call(this, type, wrappedListener ?? listener, options) + } window.open = (...args: WindowOpenArgs): WindowOpenResult => { if (!isPopupGuardArmed.value) { From e2b542b3c6d076b0e254870e6d0d2f65f6c0a1fb Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:59:44 -0700 Subject: [PATCH 10/26] chore: enable popup guard debug in development --- composables/useAdvertisements.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index 5964fe00..2924cb76 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -120,6 +120,10 @@ export default function () { } function isPopupGuardDebugEnabled(): boolean { + if (import.meta.dev) { + return true + } + const debugFlagOnWindow = (window as Window & Record)[AD_POPUP_DEBUG_WINDOW_FLAG] if (typeof debugFlagOnWindow === 'boolean') { From b5a060788aa473918036162a451747a154892419 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 01:06:16 -0700 Subject: [PATCH 11/26] refactor: simplify popup cap guard --- composables/useAdvertisements.ts | 358 +------------------------------ 1 file changed, 11 insertions(+), 347 deletions(-) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index 2924cb76..81625176 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -2,83 +2,11 @@ import { default as randomWeightedChoice } from 'random-weighted-choice' const AD_POPUP_CAP_DURATION_MS = 30 * 60 * 1000 const AD_LAST_POPUP_AT_STORAGE_KEY = 'ads-last-popup-at' -// Temporary debug toggles for popup-cap behavior: -// - window.__ADS_POPUP_GUARD_DEBUG__ = true -// - localStorage.setItem('ads-popup-guard-debug', '1') -const AD_POPUP_DEBUG_STORAGE_KEY = 'ads-popup-guard-debug' -const AD_POPUP_DEBUG_WINDOW_FLAG = '__ADS_POPUP_GUARD_DEBUG__' -const STACK_URL_REGEX = /https?:\/\/[^\s)]+/g -const DEBUG_TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']) const INTEGER_TIMESTAMP_REGEX = /^\d+$/ -const VENDOR_INTERACTION_EVENT_TYPES = new Set([ - 'click', - 'mousedown', - 'mouseup', - 'pointerdown', - 'touchstart' -]) type WindowOpenArgs = Parameters type WindowOpenResult = ReturnType -type PopupCapState = { - isActive: boolean - lastPopupAt: number | null - elapsedSinceLastPopupMs: number | null -} -type VendorPopupMatchReason = 'cross-origin-script' | 'same-origin-js-script' | 'no-user-activation' -type VendorOpenKind = 'popunder' | 'in-page-push' - -function getScriptUrlsFromStack(stack?: string): URL[] { - if (!stack) { - return [] - } - - const matches = stack.match(STACK_URL_REGEX) - - if (!matches) { - return [] - } - - const urls: URL[] = [] - - for (const rawMatch of matches) { - const normalizedUrl = rawMatch - .replace(/[),]$/, '') - .replace(/:\d+:\d+$/, '') - - try { - urls.push(new URL(normalizedUrl)) - } catch { - // Ignore malformed URLs from stack traces - } - } - - return urls -} - -function getVendorPopupMatchReason( - callerScriptUrls: URL[], - hasUserActivation: boolean -): VendorPopupMatchReason | null { - if (callerScriptUrls.length === 0) { - return hasUserActivation ? null : 'no-user-activation' - } - - const currentOrigin = window.location.origin - - for (const scriptUrl of callerScriptUrls) { - if (scriptUrl.origin !== currentOrigin) { - return 'cross-origin-script' - } - - // Keep the heuristic broad: treat same-origin static /js scripts as likely ad/vendor callers. - if (scriptUrl.pathname.startsWith('/js/')) { - return 'same-origin-js-script' - } - } - - return hasUserActivation ? null : 'no-user-activation' -} +type PopupOpenKind = 'popunder' | 'in-page-push' function getRequestedUrl(args: WindowOpenArgs): string | null { const [requestedUrl] = args @@ -86,7 +14,7 @@ function getRequestedUrl(args: WindowOpenArgs): string | null { return typeof requestedUrl === 'string' ? requestedUrl : null } -function getVendorOpenKind(args: WindowOpenArgs): VendorOpenKind { +function getPopupOpenKind(args: WindowOpenArgs): PopupOpenKind { const requestedUrl = getRequestedUrl(args) if (!requestedUrl) { @@ -119,72 +47,6 @@ export default function () { return } - function isPopupGuardDebugEnabled(): boolean { - if (import.meta.dev) { - return true - } - - const debugFlagOnWindow = (window as Window & Record)[AD_POPUP_DEBUG_WINDOW_FLAG] - - if (typeof debugFlagOnWindow === 'boolean') { - return debugFlagOnWindow - } - - try { - const rawDebugFlag = window.localStorage.getItem(AD_POPUP_DEBUG_STORAGE_KEY) - - if (!rawDebugFlag) { - return false - } - - return DEBUG_TRUTHY_VALUES.has(rawDebugFlag.trim().toLowerCase()) - } catch { - return false - } - } - - function debugPopupGuardDecision(details: { - decision: 'allowed' | 'blocked' - reason: 'vendor-cap-active' | 'vendor-cap-inactive' - vendorPopupMatchReason: VendorPopupMatchReason - vendorOpenKind: VendorOpenKind - args: WindowOpenArgs - hasUserActivation: boolean - callerScriptUrls: URL[] - capState: PopupCapState - recordedPopupAt?: number - openAttemptOutcome?: 'opened' | 'blocked-or-null' | 'threw-error' - openError?: string - capRecordedAt?: number - }) { - if (!isPopupGuardDebugEnabled()) { - return - } - - const [requestedUrl, target, windowFeatures] = details.args - - console.debug('[ads-popup-guard]', { - decision: details.decision, - reason: details.reason, - vendorPopupMatchReason: details.vendorPopupMatchReason, - vendorOpenKind: details.vendorOpenKind, - requestedUrl: typeof requestedUrl === 'string' ? requestedUrl : null, - target: typeof target === 'string' ? target : null, - windowFeatures: typeof windowFeatures === 'string' ? windowFeatures : null, - hasUserActivation: details.hasUserActivation, - callerScriptUrlCount: details.callerScriptUrls.length, - callerScriptUrls: details.callerScriptUrls.slice(0, 5).map(scriptUrl => scriptUrl.href), - capDurationMs: AD_POPUP_CAP_DURATION_MS, - capActive: details.capState.isActive, - lastPopupAt: details.capState.lastPopupAt, - elapsedSinceLastPopupMs: details.capState.elapsedSinceLastPopupMs, - recordedPopupAt: details.recordedPopupAt ?? null, - capRecordedAt: details.capRecordedAt ?? null, - openAttemptOutcome: details.openAttemptOutcome ?? null, - openError: details.openError ?? null - }) - } - function parseStoredLastPopupAt(rawLastPopupAt: string, now: number): number | null { const normalizedRawLastPopupAt = rawLastPopupAt.trim() @@ -254,240 +116,42 @@ export default function () { } } - function getAdPopupCapState(now = Date.now()): PopupCapState { - const lastAdPopupAt = getLastAdPopupAt(now) - - if (!lastAdPopupAt) { - return { - isActive: false, - lastPopupAt: null, - elapsedSinceLastPopupMs: null - } - } - - const elapsedSinceLastPopupMs = now - lastAdPopupAt + function isAdPopupCapActive(now = Date.now()): boolean { + const lastPopupAt = getLastAdPopupAt(now) - return { - isActive: elapsedSinceLastPopupMs < AD_POPUP_CAP_DURATION_MS, - lastPopupAt: lastAdPopupAt, - elapsedSinceLastPopupMs - } - } - - function isAdPopupCapActive(): boolean { - return getAdPopupCapState().isActive + return lastPopupAt !== null && now - lastPopupAt < AD_POPUP_CAP_DURATION_MS } if (!isPopupGuardInstalled.value) { const originalWindowOpen = window.open.bind(window) - const originalAddEventListener = EventTarget.prototype.addEventListener - const originalRemoveEventListener = EventTarget.prototype.removeEventListener - const wrappedVendorInteractionListeners = new WeakMap< - EventListenerOrEventListenerObject, - Map - >() - - EventTarget.prototype.addEventListener = function ( - type: string, - listener: EventListenerOrEventListenerObject | null, - options?: boolean | AddEventListenerOptions - ) { - if (!listener || !VENDOR_INTERACTION_EVENT_TYPES.has(type)) { - originalAddEventListener.call(this, type, listener, options) - - return - } - - const callerScriptUrls = getScriptUrlsFromStack(new Error().stack) - const vendorPopupMatchReason = getVendorPopupMatchReason(callerScriptUrls, true) - - if (!vendorPopupMatchReason || vendorPopupMatchReason === 'no-user-activation') { - originalAddEventListener.call(this, type, listener, options) - - return - } - - let wrappedListener = wrappedVendorInteractionListeners.get(listener)?.get(type) - - if (!wrappedListener) { - wrappedListener = function (this: EventTarget, event: Event) { - if (isPopupGuardArmed.value && isAdPopupCapActive()) { - if (isPopupGuardDebugEnabled()) { - const capState = getAdPopupCapState() - - console.debug('[ads-popup-guard]', { - decision: 'blocked', - reason: 'vendor-cap-active', - guard: 'interaction-listener', - eventType: event.type, - vendorPopupMatchReason, - callerScriptUrlCount: callerScriptUrls.length, - callerScriptUrls: callerScriptUrls.slice(0, 5).map(scriptUrl => scriptUrl.href), - capDurationMs: AD_POPUP_CAP_DURATION_MS, - capActive: capState.isActive, - lastPopupAt: capState.lastPopupAt, - elapsedSinceLastPopupMs: capState.elapsedSinceLastPopupMs - }) - } - - return - } - - if (typeof listener === 'function') { - listener.call(this, event) - - return - } - - listener.handleEvent.call(listener, event) - } - - let wrappedListenersByType = wrappedVendorInteractionListeners.get(listener) - - if (!wrappedListenersByType) { - wrappedListenersByType = new Map() - wrappedVendorInteractionListeners.set(listener, wrappedListenersByType) - } - - wrappedListenersByType.set(type, wrappedListener) - } - - originalAddEventListener.call(this, type, wrappedListener, options) - } - - EventTarget.prototype.removeEventListener = function ( - type: string, - listener: EventListenerOrEventListenerObject | null, - options?: boolean | EventListenerOptions - ) { - if (!listener || !VENDOR_INTERACTION_EVENT_TYPES.has(type)) { - originalRemoveEventListener.call(this, type, listener, options) - - return - } - - const wrappedListener = wrappedVendorInteractionListeners.get(listener)?.get(type) - - originalRemoveEventListener.call(this, type, wrappedListener ?? listener, options) - } window.open = (...args: WindowOpenArgs): WindowOpenResult => { if (!isPopupGuardArmed.value) { return originalWindowOpen(...args) } - const userActivation = (window.navigator as Navigator & { - // Legacy browsers may not expose navigator.userActivation. - userActivation?: { isActive: boolean } - }).userActivation - - // Default to true when userActivation is unavailable so older browsers keep allowing - // popups instead of breaking ad flows entirely; this trades stricter detection for compatibility. - const hasUserActivation = userActivation?.isActive ?? true - const callerScriptUrls = getScriptUrlsFromStack(new Error().stack) - const vendorPopupMatchReason = getVendorPopupMatchReason(callerScriptUrls, hasUserActivation) - const vendorOpenKind = getVendorOpenKind(args) - - if (!vendorPopupMatchReason) { - return originalWindowOpen(...args) - } - - if (vendorOpenKind === 'in-page-push') { - debugPopupGuardDecision({ - decision: 'allowed', - reason: 'vendor-cap-inactive', - vendorPopupMatchReason, - vendorOpenKind, - args, - hasUserActivation, - callerScriptUrls, - capState: getAdPopupCapState() - }) - + if (getPopupOpenKind(args) === 'in-page-push') { return originalWindowOpen(...args) } - const capState = getAdPopupCapState() - - if (capState.isActive) { - debugPopupGuardDecision({ - decision: 'blocked', - reason: 'vendor-cap-active', - vendorPopupMatchReason, - vendorOpenKind, - args, - hasUserActivation, - callerScriptUrls, - capState - }) - + if (isAdPopupCapActive()) { return null } - const attemptedAt = Date.now() - recordAdPopupOpened(attemptedAt) - - try { - const popupHandle = originalWindowOpen(...args) - - if (popupHandle) { - debugPopupGuardDecision({ - decision: 'allowed', - reason: 'vendor-cap-inactive', - vendorPopupMatchReason, - vendorOpenKind, - args, - hasUserActivation, - callerScriptUrls, - capState, - recordedPopupAt: attemptedAt, - capRecordedAt: attemptedAt, - openAttemptOutcome: 'opened' - }) - } else { - debugPopupGuardDecision({ - decision: 'allowed', - reason: 'vendor-cap-inactive', - vendorPopupMatchReason, - vendorOpenKind, - args, - hasUserActivation, - callerScriptUrls, - capState, - capRecordedAt: attemptedAt, - openAttemptOutcome: 'blocked-or-null' - }) - } + recordAdPopupOpened() - return popupHandle - } catch (error) { - debugPopupGuardDecision({ - decision: 'allowed', - reason: 'vendor-cap-inactive', - vendorPopupMatchReason, - vendorOpenKind, - args, - hasUserActivation, - callerScriptUrls, - capState, - capRecordedAt: attemptedAt, - openAttemptOutcome: 'threw-error', - openError: error instanceof Error ? error.message : String(error) - }) - - throw error - } + return originalWindowOpen(...args) } isPopupGuardInstalled.value = true } - // Phase 1: stop injecting ad scripts while the 30-minute popup cap is active. + // Stop injecting ad scripts while the 30-minute popup cap is active. if (isAdPopupCapActive()) { return } - // Phase 2 (arming): once scripts load, guard vendor-like popups with the first-party cap. + // Once scripts load, guard future popunder opens with the first-party cap. isPopupGuardArmed.value = true const popunderAds = [ From beb5218c90142982b17ded83b33aef70994dfcb8 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 01:06:25 -0700 Subject: [PATCH 12/26] Revert "fix: defer ad arming until after first click" This reverts commit 85d891efff647f94c9da7dc3785ff7a3e7f59c0b. --- pages/index.vue | 11 +---------- pages/posts/[domain].vue | 11 +---------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/pages/index.vue b/pages/index.vue index d11b5541..cfeb2801 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -122,7 +122,6 @@ */ onMounted(() => { const hasLoadedAds = ref(false) - let adLoadTimeoutId: number | null = null watch([hasInteracted, isPremium], ([hasInteracted, isPremium]) => { if (hasLoadedAds.value) { @@ -139,16 +138,8 @@ hasLoadedAds.value = true - adLoadTimeoutId = window.setTimeout(() => { - useAdvertisements() - }, 0) + useAdvertisements() }, { immediate: true }) - - onBeforeUnmount(() => { - if (adLoadTimeoutId !== null) { - window.clearTimeout(adLoadTimeoutId) - } - }) }) const featuredDomains = [ diff --git a/pages/posts/[domain].vue b/pages/posts/[domain].vue index 38f8d8a0..ec982b6a 100644 --- a/pages/posts/[domain].vue +++ b/pages/posts/[domain].vue @@ -33,7 +33,6 @@ */ onMounted(() => { const hasLoadedAds = ref(false) - let adLoadTimeoutId: number | null = null watch([hasInteracted, isPremium], ([hasInteracted, isPremium]) => { if (hasLoadedAds.value) { @@ -50,16 +49,8 @@ hasLoadedAds.value = true - adLoadTimeoutId = window.setTimeout(() => { - useAdvertisements() - }, 0) + useAdvertisements() }, { immediate: true }) - - onBeforeUnmount(() => { - if (adLoadTimeoutId !== null) { - window.clearTimeout(adLoadTimeoutId) - } - }) }) /** From 6edda36e416af504b2a335db23b6bc56f3c3bad0 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 01:17:27 -0700 Subject: [PATCH 13/26] fix: bypass popup guard for trusted app opens --- components/layout/FeedbackButton.vue | 2 +- components/pages/posts/post/PostSource.vue | 2 +- composables/useAdvertisements.ts | 24 ++++++++++++++++++++++ pages/index.vue | 2 +- pages/posts/[domain].vue | 4 ++-- pages/premium/dashboard.vue | 2 +- 6 files changed, 30 insertions(+), 6 deletions(-) diff --git a/components/layout/FeedbackButton.vue b/components/layout/FeedbackButton.vue index 38eb8a3f..0010e664 100644 --- a/components/layout/FeedbackButton.vue +++ b/components/layout/FeedbackButton.vue @@ -3,7 +3,7 @@ import { project } from '@/config/project' function handleClick(event: Event) { - window.open(`https://feedback.${project.urls.production.hostname}`, '_blank') + openTrustedWindow(`https://feedback.${project.urls.production.hostname}`, '_blank') } diff --git a/components/pages/posts/post/PostSource.vue b/components/pages/posts/post/PostSource.vue index 7aa33afe..019a1aa9 100644 --- a/components/pages/posts/post/PostSource.vue +++ b/components/pages/posts/post/PostSource.vue @@ -90,7 +90,7 @@ return } - window.open(url, '_blank') + openTrustedWindow(url, '_blank') } function onMenuOpen() { diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index 81625176..ac641872 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -2,6 +2,7 @@ import { default as randomWeightedChoice } from 'random-weighted-choice' const AD_POPUP_CAP_DURATION_MS = 30 * 60 * 1000 const AD_LAST_POPUP_AT_STORAGE_KEY = 'ads-last-popup-at' +const AD_TRUSTED_WINDOW_OPEN_BYPASS_STATE_KEY = 'ads-trusted-window-open-bypass-next' const INTEGER_TIMESTAMP_REGEX = /^\d+$/ type WindowOpenArgs = Parameters @@ -42,6 +43,7 @@ export default function () { const isPopupGuardInstalled = useState('ads-popup-guard-installed', () => false) const isPopupGuardArmed = useState('ads-popup-guard-armed', () => false) const lastAdPopupAtInMemory = useState('ads-last-popup-at-in-memory', () => null) + const shouldBypassNextWindowOpenGuard = useState(AD_TRUSTED_WINDOW_OPEN_BYPASS_STATE_KEY, () => false) if (!import.meta.client) { return @@ -126,6 +128,12 @@ export default function () { const originalWindowOpen = window.open.bind(window) window.open = (...args: WindowOpenArgs): WindowOpenResult => { + if (shouldBypassNextWindowOpenGuard.value) { + shouldBypassNextWindowOpenGuard.value = false + + return originalWindowOpen(...args) + } + if (!isPopupGuardArmed.value) { return originalWindowOpen(...args) } @@ -293,6 +301,22 @@ export default function () { }) } +export function openTrustedWindow(...args: WindowOpenArgs): WindowOpenResult { + if (!import.meta.client) { + return null + } + + const shouldBypassNextWindowOpenGuard = useState(AD_TRUSTED_WINDOW_OPEN_BYPASS_STATE_KEY, () => false) + + shouldBypassNextWindowOpenGuard.value = true + + try { + return window.open(...args) + } finally { + shouldBypassNextWindowOpenGuard.value = false + } +} + export function useChatWithAiReferral() { const chatWithAiReferrals = [ { diff --git a/pages/index.vue b/pages/index.vue index cfeb2801..34efe57c 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -92,7 +92,7 @@ description: 'You sent too many requests in a short period of time', action: { label: 'Verify I am not a Bot', - onClick: () => window.open(config.public.apiUrl + '/status', '_blank') + onClick: () => openTrustedWindow(config.public.apiUrl + '/status', '_blank') } }) break diff --git a/pages/posts/[domain].vue b/pages/posts/[domain].vue index ec982b6a..065f2f29 100644 --- a/pages/posts/[domain].vue +++ b/pages/posts/[domain].vue @@ -252,7 +252,7 @@ description: 'You sent too many requests in a short period of time', action: { label: 'Verify I am not a Bot', - onClick: () => window.open(config.public.apiUrl + '/status', '_blank') + onClick: () => openTrustedWindow(config.public.apiUrl + '/status', '_blank') } }) break @@ -328,7 +328,7 @@ const resolvedTagUrl = router.resolve(tagUrl).href - window.open(resolvedTagUrl, '_blank') + openTrustedWindow(resolvedTagUrl, '_blank') } async function onLoadNextPostPage() { diff --git a/pages/premium/dashboard.vue b/pages/premium/dashboard.vue index 8db169c2..c99380f2 100644 --- a/pages/premium/dashboard.vue +++ b/pages/premium/dashboard.vue @@ -82,7 +82,7 @@ return } - window.open(PLATFORM_URLS[platformOfPurchase.value], '_blank', 'noopener,noreferrer') + openTrustedWindow(PLATFORM_URLS[platformOfPurchase.value], '_blank', 'noopener,noreferrer') window._paq?.push(['trackEvent', 'Premium', 'Click "Manage subscription"', platformOfPurchase.value]) } From 90820bf22fe4e8853d538ece7f3ac53ae90ac366 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 01:21:21 -0700 Subject: [PATCH 14/26] chore: restore dev popup guard logs --- composables/useAdvertisements.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index ac641872..1b69a4a4 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -9,6 +9,17 @@ type WindowOpenArgs = Parameters type WindowOpenResult = ReturnType type PopupOpenKind = 'popunder' | 'in-page-push' +function logAdPopupGuard(event: string, details?: Record) { + if (!import.meta.dev) { + return + } + + console.debug('[ads-popup-guard]', { + event, + ...details + }) +} + function getRequestedUrl(args: WindowOpenArgs): string | null { const [requestedUrl] = args @@ -131,6 +142,8 @@ export default function () { if (shouldBypassNextWindowOpenGuard.value) { shouldBypassNextWindowOpenGuard.value = false + logAdPopupGuard('trusted-open-bypass') + return originalWindowOpen(...args) } @@ -139,15 +152,23 @@ export default function () { } if (getPopupOpenKind(args) === 'in-page-push') { + logAdPopupGuard('allow-in-page-push') + return originalWindowOpen(...args) } if (isAdPopupCapActive()) { + logAdPopupGuard('block-capped-popunder') + return null } recordAdPopupOpened() + logAdPopupGuard('allow-popunder', { + cappedUntil: Date.now() + AD_POPUP_CAP_DURATION_MS + }) + return originalWindowOpen(...args) } @@ -156,6 +177,8 @@ export default function () { // Stop injecting ad scripts while the 30-minute popup cap is active. if (isAdPopupCapActive()) { + logAdPopupGuard('skip-script-injection-while-capped') + return } From 0cace6bda0a3465a033e998d3f6c4ecdaccd81d3 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 01:26:53 -0700 Subject: [PATCH 15/26] fix: exempt push provider opens from popup cap --- composables/useAdvertisements.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index 1b69a4a4..cad086c4 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -3,6 +3,7 @@ import { default as randomWeightedChoice } from 'random-weighted-choice' const AD_POPUP_CAP_DURATION_MS = 30 * 60 * 1000 const AD_LAST_POPUP_AT_STORAGE_KEY = 'ads-last-popup-at' const AD_TRUSTED_WINDOW_OPEN_BYPASS_STATE_KEY = 'ads-trusted-window-open-bypass-next' +const IN_PAGE_PUSH_HOSTNAMES = new Set(['hotbsizovu.today', 'udzpel.com']) const INTEGER_TIMESTAMP_REGEX = /^\d+$/ type WindowOpenArgs = Parameters @@ -36,6 +37,13 @@ function getPopupOpenKind(args: WindowOpenArgs): PopupOpenKind { try { const parsedUrl = new URL(requestedUrl, window.location.href) + if ( + IN_PAGE_PUSH_HOSTNAMES.has(parsedUrl.hostname) + || Array.from(IN_PAGE_PUSH_HOSTNAMES).some(hostname => parsedUrl.hostname.endsWith(`.${hostname}`)) + ) { + return 'in-page-push' + } + for (const searchParamKey of parsedUrl.searchParams.keys()) { if (searchParamKey.startsWith('inpage.')) { return 'in-page-push' @@ -142,7 +150,9 @@ export default function () { if (shouldBypassNextWindowOpenGuard.value) { shouldBypassNextWindowOpenGuard.value = false - logAdPopupGuard('trusted-open-bypass') + logAdPopupGuard('trusted-open-bypass', { + requestedUrl: getRequestedUrl(args) + }) return originalWindowOpen(...args) } @@ -152,13 +162,17 @@ export default function () { } if (getPopupOpenKind(args) === 'in-page-push') { - logAdPopupGuard('allow-in-page-push') + logAdPopupGuard('allow-in-page-push', { + requestedUrl: getRequestedUrl(args) + }) return originalWindowOpen(...args) } if (isAdPopupCapActive()) { - logAdPopupGuard('block-capped-popunder') + logAdPopupGuard('block-capped-popunder', { + requestedUrl: getRequestedUrl(args) + }) return null } @@ -166,6 +180,7 @@ export default function () { recordAdPopupOpened() logAdPopupGuard('allow-popunder', { + requestedUrl: getRequestedUrl(args), cappedUntil: Date.now() + AD_POPUP_CAP_DURATION_MS }) From fe6d0a0d00ada9f8ea1a2cc6165733121f179cd3 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 01:32:12 -0700 Subject: [PATCH 16/26] fix: treat hotsoz push redirects as in-page opens --- composables/useAdvertisements.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index cad086c4..11b32f0c 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -3,7 +3,7 @@ import { default as randomWeightedChoice } from 'random-weighted-choice' const AD_POPUP_CAP_DURATION_MS = 30 * 60 * 1000 const AD_LAST_POPUP_AT_STORAGE_KEY = 'ads-last-popup-at' const AD_TRUSTED_WINDOW_OPEN_BYPASS_STATE_KEY = 'ads-trusted-window-open-bypass-next' -const IN_PAGE_PUSH_HOSTNAMES = new Set(['hotbsizovu.today', 'udzpel.com']) +const IN_PAGE_PUSH_HOSTNAMES = new Set(['hotbsizovu.today', 'udzpel.com', 'hotsoz.com']) const INTEGER_TIMESTAMP_REGEX = /^\d+$/ type WindowOpenArgs = Parameters From 06177ee0e6d400143ecdc9ea2614e4cf87c02192 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 01:40:46 -0700 Subject: [PATCH 17/26] chore: log skipped ad scripts while capped --- composables/useAdvertisements.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index 11b32f0c..77200f71 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -143,6 +143,26 @@ export default function () { return lastPopupAt !== null && now - lastPopupAt < AD_POPUP_CAP_DURATION_MS } + function getAdPopupCapLogDetails(now = Date.now()): Record { + const lastPopupAt = getLastAdPopupAt(now) + + if (lastPopupAt === null) { + return { + lastPopupAt: null, + cappedUntil: null, + remainingMs: null + } + } + + const cappedUntil = lastPopupAt + AD_POPUP_CAP_DURATION_MS + + return { + lastPopupAt, + cappedUntil, + remainingMs: Math.max(0, cappedUntil - now) + } + } + if (!isPopupGuardInstalled.value) { const originalWindowOpen = window.open.bind(window) @@ -171,7 +191,8 @@ export default function () { if (isAdPopupCapActive()) { logAdPopupGuard('block-capped-popunder', { - requestedUrl: getRequestedUrl(args) + requestedUrl: getRequestedUrl(args), + ...getAdPopupCapLogDetails() }) return null @@ -192,7 +213,11 @@ export default function () { // Stop injecting ad scripts while the 30-minute popup cap is active. if (isAdPopupCapActive()) { - logAdPopupGuard('skip-script-injection-while-capped') + logAdPopupGuard('skip-script-injection-while-capped', { + popunderScript: popunderScript.value || null, + pushScript: pushScript.value || null, + ...getAdPopupCapLogDetails() + }) return } From 19cca555e370e04e7b6164ab9701395e1c265559 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 02:13:19 -0700 Subject: [PATCH 18/26] refactor: centralize ad provider metadata --- composables/useAdvertisements.ts | 214 ++++++++++--------------------- 1 file changed, 69 insertions(+), 145 deletions(-) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index 77200f71..46eaf30a 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -3,12 +3,58 @@ import { default as randomWeightedChoice } from 'random-weighted-choice' const AD_POPUP_CAP_DURATION_MS = 30 * 60 * 1000 const AD_LAST_POPUP_AT_STORAGE_KEY = 'ads-last-popup-at' const AD_TRUSTED_WINDOW_OPEN_BYPASS_STATE_KEY = 'ads-trusted-window-open-bypass-next' -const IN_PAGE_PUSH_HOSTNAMES = new Set(['hotbsizovu.today', 'udzpel.com', 'hotsoz.com']) const INTEGER_TIMESTAMP_REGEX = /^\d+$/ +const AD_SCRIPT_ATTRIBUTES = { + async: false, + defer: true, + crossorigin: 'anonymous' as const +} type WindowOpenArgs = Parameters type WindowOpenResult = ReturnType type PopupOpenKind = 'popunder' | 'in-page-push' +type WeightedAd = { + id: string + weight: number +} +type PushAd = WeightedAd & { + inPageOpenHostnames: string[] +} + +const POPUNDER_ADS: WeightedAd[] = [ + { + id: 'https:////ellipticaltrack.com/c.D/9v6/bW2/5aleSRW/Qj9SNojrA/zWMxTuk_zvNoiJ0S2kMgDBMux_OXTCMU3Z', + weight: 1 + }, + { + id: '/js/popunder2.js?v=10', + weight: 1 + } +] + +const PUSH_ADS: PushAd[] = [ + { + id: 'https://hotbsizovu.today/process.js?id=1300335215&p1=sub1&p2=sub2&p3=sub3&p4=sub4', + weight: 0.15, + inPageOpenHostnames: ['hotbsizovu.today'] + }, + { + id: 'https://udzpel.com/pw/waWQiOjExOTMwMzUsInNpZCI6MTQwNzY1NSwid2lkIjo2ODMzODcsInNyYyI6Mn0=eyJ.js', + weight: 1, + inPageOpenHostnames: ['udzpel.com', 'hotsoz.com'] + } +] + +const CHAT_WITH_AI_REFERRALS: WeightedAd[] = [ + { + id: 'https://crushon.ai/search?s={query}&ref=zdnmmzy&mist=1', + weight: 0.5 + }, + { + id: 'https://spicychat.ai/?public_characters_alias%2Fsort%2Fnum_messages_24h%3Adesc[query]={query}&ref=ode2nzn', + weight: 0.5 + } +] function logAdPopupGuard(event: string, details?: Record) { if (!import.meta.dev) { @@ -27,7 +73,17 @@ function getRequestedUrl(args: WindowOpenArgs): string | null { return typeof requestedUrl === 'string' ? requestedUrl : null } -function getPopupOpenKind(args: WindowOpenArgs): PopupOpenKind { +function hostnameMatches(hostname: string, allowedHostnames: string[]): boolean { + return allowedHostnames.some(allowedHostname => { + return hostname === allowedHostname || hostname.endsWith(`.${allowedHostname}`) + }) +} + +function findPushAdByScriptId(scriptId: string): PushAd | null { + return PUSH_ADS.find(pushAd => pushAd.id === scriptId) ?? null +} + +function getPopupOpenKind(args: WindowOpenArgs, activePushAd: PushAd | null): PopupOpenKind { const requestedUrl = getRequestedUrl(args) if (!requestedUrl) { @@ -37,10 +93,7 @@ function getPopupOpenKind(args: WindowOpenArgs): PopupOpenKind { try { const parsedUrl = new URL(requestedUrl, window.location.href) - if ( - IN_PAGE_PUSH_HOSTNAMES.has(parsedUrl.hostname) - || Array.from(IN_PAGE_PUSH_HOSTNAMES).some(hostname => parsedUrl.hostname.endsWith(`.${hostname}`)) - ) { + if (activePushAd && hostnameMatches(parsedUrl.hostname, activePushAd.inPageOpenHostnames)) { return 'in-page-push' } @@ -100,7 +153,6 @@ export default function () { ) { resolvedLastPopupAt = inMemoryLastPopupAt } else { - // Reset invalid or future in-memory values so they do not over-block. lastAdPopupAtInMemory.value = null } } @@ -119,7 +171,7 @@ export default function () { } } } catch { - // Ignore storage failures and use in-memory fallback + // Ignore storage failures and use in-memory fallback. } lastAdPopupAtInMemory.value = resolvedLastPopupAt @@ -133,7 +185,7 @@ export default function () { try { window.localStorage.setItem(AD_LAST_POPUP_AT_STORAGE_KEY, String(at)) } catch { - // Ignore storage failures and keep the in-memory fallback + // Ignore storage failures and keep the in-memory fallback. } } @@ -167,6 +219,8 @@ export default function () { const originalWindowOpen = window.open.bind(window) window.open = (...args: WindowOpenArgs): WindowOpenResult => { + const activePushAd = findPushAdByScriptId(pushScript.value) + if (shouldBypassNextWindowOpenGuard.value) { shouldBypassNextWindowOpenGuard.value = false @@ -181,7 +235,7 @@ export default function () { return originalWindowOpen(...args) } - if (getPopupOpenKind(args) === 'in-page-push') { + if (getPopupOpenKind(args, activePushAd) === 'in-page-push') { logAdPopupGuard('allow-in-page-push', { requestedUrl: getRequestedUrl(args) }) @@ -211,7 +265,6 @@ export default function () { isPopupGuardInstalled.value = true } - // Stop injecting ad scripts while the 30-minute popup cap is active. if (isAdPopupCapActive()) { logAdPopupGuard('skip-script-injection-while-capped', { popunderScript: popunderScript.value || null, @@ -222,143 +275,25 @@ export default function () { return } - // Once scripts load, guard future popunder opens with the first-party cap. isPopupGuardArmed.value = true - const popunderAds = [ - /** - * ExoClick - * Pros: - * Cons: - */ - // { - // id: '', - // weight: 1, - // }, - /** - * Adsession - * Pros: - * Cons: - */ - // { - // id: '/js/popunder.js?v=7', - // weight: 1, - // }, - /** - * HilltopAds - * Pros: Good min payout - * Cons: Not fixed CPM, Low Revenue (70) - */ - { - id: 'https:////ellipticaltrack.com/c.D/9v6/bW2/5aleSRW/Qj9SNojrA/zWMxTuk_zvNoiJ0S2kMgDBMux_OXTCMU3Z', - weight: 1 - }, - /** - * Clickadu - * Pros: Good CPM (2.1) - * Cons: Low revenue (70), Does not count visits well, (!!!) Clears console - */ - { - id: '/js/popunder2.js?v=10', - weight: 1 - } - /** - * AdMaven - * Pros: - * Cons: Does not open in a new tab, Possible malware: ads open requests to social media login?? - */ - // { - // id: 'https://d3pk1qkob3uzgp.cloudfront.net/?kqkpd=1171073', - // weight: 1, - // }, - /** - * AdsCarat - * Pros: Great CPM (2.5) - * Cons: Low Revenue (25) | Does not count visits well | Reloads website once? - */ - // { - // id: 'https://hp.scrannyplacebo.com/rMGqiS1acWcIq4LyI/oQRmJ', - // weight: 1 - // } - ] - - const pushAds = [ - /** - * PartnersHouse - * Pros: - * Cons: Low revenue (17) - */ - { - id: 'https://hotbsizovu.today/process.js?id=1300335215&p1=sub1&p2=sub2&p3=sub3&p4=sub4', - weight: 0.15 - }, - /** - * HilltopAds - * Pros: - * Cons: Very Low Revenue (1.96) - */ - // { - // id: '\\/\\/ellipticaltrack.com\\/b\\/XeV.sad\\/GJlb0jYvWxcR\\/HewmG9ou\\/ZWUXlukZPMTJY_yMOQTBQe5VMsjVI\\/tuNbjOIh5MNDDpkryvMSwO', - // weight: 0.15, - // }, - /** - * Clickadu - * Pros: - * Cons: Low Revenue (4.64) - */ - // { - // id: '//guidepaparazzisurface.com/bultykh/ipp24/7/bazinga/2065744', - // weight: 0.15, - // }, - /** - * AdsCarat - * Pros: - * Cons: Extremely low revenue (0.50) - */ - // { - // id: '//jn.astelicbanes.com/sgC9H1j3tpX/121206', - // weight: 0.15 - // }, - /** - * EvaDav - * Pros: Fixed weekly pay () - * Cons: - */ - { - id: 'https://udzpel.com/pw/waWQiOjExOTMwMzUsInNpZCI6MTQwNzY1NSwid2lkIjo2ODMzODcsInNyYyI6Mn0=eyJ.js', - weight: 1 - } - ] - - // Load popunder ad if not already loaded if (!popunderScript.value) { - const selectedPopunder = randomWeightedChoice(popunderAds) - popunderScript.value = selectedPopunder + popunderScript.value = randomWeightedChoice(POPUNDER_ADS) } - // Load push notification ad if not already loaded if (!pushScript.value) { - const selectedPush = randomWeightedChoice(pushAds) - pushScript.value = selectedPush + pushScript.value = randomWeightedChoice(PUSH_ADS) } - // Load selected ads useHead({ script: [ { src: popunderScript.value, - async: false, - defer: true, - - // Fix for CORS issues - https://unhead.unjs.io/usage/composables/use-script#referrerpolicy-and-crossorigin - crossorigin: 'anonymous' + ...AD_SCRIPT_ATTRIBUTES }, { src: pushScript.value, - async: false, - defer: true, - - crossorigin: 'anonymous' + ...AD_SCRIPT_ATTRIBUTES } ] }) @@ -381,19 +316,8 @@ export function openTrustedWindow(...args: WindowOpenArgs): WindowOpenResult { } export function useChatWithAiReferral() { - const chatWithAiReferrals = [ - { - id: 'https://crushon.ai/search?s={query}&ref=zdnmmzy&mist=1', - weight: 0.5 - }, - { - id: 'https://spicychat.ai/?public_characters_alias%2Fsort%2Fnum_messages_24h%3Adesc[query]={query}&ref=ode2nzn', - weight: 0.5 - } - ] - const chatWithAiReferralTemplate = useState('chat-with-ai-referral', () => { - return randomWeightedChoice(chatWithAiReferrals) + return randomWeightedChoice(CHAT_WITH_AI_REFERRALS) }) return { From db4bfe8abd0f65f21fa1f40697359e3edd5126d6 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 02:24:04 -0700 Subject: [PATCH 19/26] refactor: restore ad provider notes --- composables/useAdvertisements.ts | 279 +++++++++++++++++++++++++------ 1 file changed, 230 insertions(+), 49 deletions(-) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index 46eaf30a..a16cb938 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -1,9 +1,10 @@ import { default as randomWeightedChoice } from 'random-weighted-choice' -const AD_POPUP_CAP_DURATION_MS = 30 * 60 * 1000 +const AD_POPUP_CAP_DURATION_MS = 20 * 60 * 1000 const AD_LAST_POPUP_AT_STORAGE_KEY = 'ads-last-popup-at' const AD_TRUSTED_WINDOW_OPEN_BYPASS_STATE_KEY = 'ads-trusted-window-open-bypass-next' const INTEGER_TIMESTAMP_REGEX = /^\d+$/ +const IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES = ['inpage.'] as const const AD_SCRIPT_ATTRIBUTES = { async: false, defer: true, @@ -13,49 +14,198 @@ const AD_SCRIPT_ATTRIBUTES = { type WindowOpenArgs = Parameters type WindowOpenResult = ReturnType type PopupOpenKind = 'popunder' | 'in-page-push' +type PopupClassification = { + kind: PopupOpenKind + hostnames?: string[] + searchParamPrefixes?: readonly string[] +} type WeightedAd = { id: string weight: number } -type PushAd = WeightedAd & { - inPageOpenHostnames: string[] +type AdProvider = { + name: string + ads: WeightedAd[] + popupClassification?: PopupClassification } -const POPUNDER_ADS: WeightedAd[] = [ +const POPUNDER_AD_PROVIDERS: AdProvider[] = [ + /** + * ExoClick + * Pros: + * Cons: + */ + // { + // name: 'ExoClick', + // ads: [ + // { + // id: '', + // weight: 1 + // } + // ] + // }, + /** + * Adsession + * Pros: + * Cons: + */ + // { + // name: 'Adsession', + // ads: [ + // { + // id: '/js/popunder.js?v=7', + // weight: 1 + // } + // ] + // }, + /** + * HilltopAds + * Pros: Good min payout + * Cons: Not fixed CPM, Low Revenue (70) + */ { - id: 'https:////ellipticaltrack.com/c.D/9v6/bW2/5aleSRW/Qj9SNojrA/zWMxTuk_zvNoiJ0S2kMgDBMux_OXTCMU3Z', - weight: 1 + name: 'HilltopAds', + ads: [ + { + id: 'https:////ellipticaltrack.com/c.D/9v6/bW2/5aleSRW/Qj9SNojrA/zWMxTuk_zvNoiJ0S2kMgDBMux_OXTCMU3Z', + weight: 1 + } + ] }, + /** + * Clickadu + * Pros: Good CPM (2.1) + * Cons: Low revenue (70), Does not count visits well, (!!!) Clears console + */ { - id: '/js/popunder2.js?v=10', - weight: 1 - } -] - -const PUSH_ADS: PushAd[] = [ - { - id: 'https://hotbsizovu.today/process.js?id=1300335215&p1=sub1&p2=sub2&p3=sub3&p4=sub4', - weight: 0.15, - inPageOpenHostnames: ['hotbsizovu.today'] + name: 'Clickadu', + ads: [ + { + id: '/js/popunder2.js?v=10', + weight: 1 + } + ] }, - { - id: 'https://udzpel.com/pw/waWQiOjExOTMwMzUsInNpZCI6MTQwNzY1NSwid2lkIjo2ODMzODcsInNyYyI6Mn0=eyJ.js', - weight: 1, - inPageOpenHostnames: ['udzpel.com', 'hotsoz.com'] - } + /** + * AdMaven + * Pros: + * Cons: Does not open in a new tab, Possible malware: ads open requests to social media login?? + */ + // { + // name: 'AdMaven', + // ads: [ + // { + // id: 'https://d3pk1qkob3uzgp.cloudfront.net/?kqkpd=1171073', + // weight: 1 + // } + // ] + // }, + /** + * AdsCarat + * Pros: Great CPM (2.5) + * Cons: Low Revenue (25) | Does not count visits well | Reloads website once? + */ + // { + // name: 'AdsCarat', + // ads: [ + // { + // id: 'https://hp.scrannyplacebo.com/rMGqiS1acWcIq4LyI/oQRmJ', + // weight: 1 + // } + // ] + // } ] -const CHAT_WITH_AI_REFERRALS: WeightedAd[] = [ +const PUSH_AD_PROVIDERS: AdProvider[] = [ + /** + * PartnersHouse + * Pros: + * Cons: Low revenue (17) + */ { - id: 'https://crushon.ai/search?s={query}&ref=zdnmmzy&mist=1', - weight: 0.5 + name: 'PartnersHouse', + popupClassification: { + kind: 'in-page-push', + hostnames: ['hotbsizovu.today', 'hotsoz.com'], + searchParamPrefixes: IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES + }, + ads: [ + { + id: 'https://hotbsizovu.today/process.js?id=1300335215&p1=sub1&p2=sub2&p3=sub3&p4=sub4', + weight: 0.15 + } + ] }, + /** + * HilltopAds + * Pros: + * Cons: Very Low Revenue (1.96) + */ + // { + // name: 'HilltopAds', + // ads: [ + // { + // id: '\\/\\/ellipticaltrack.com\\/b\\/XeV.sad\\/GJlb0jYvWxcR\\/HewmG9ou\\/ZWUXlukZPMTJY_yMOQTBQe5VMsjVI\\/tuNbjOIh5MNDDpkryvMSwO', + // weight: 0.15 + // } + // ] + // }, + /** + * Clickadu + * Pros: + * Cons: Low Revenue (4.64) + */ + // { + // name: 'Clickadu', + // ads: [ + // { + // id: '//guidepaparazzisurface.com/bultykh/ipp24/7/bazinga/2065744', + // weight: 0.15 + // } + // ] + // }, + /** + * AdsCarat + * Pros: + * Cons: Extremely low revenue (0.50) + */ + // { + // name: 'AdsCarat', + // ads: [ + // { + // id: '//jn.astelicbanes.com/sgC9H1j3tpX/121206', + // weight: 0.15 + // } + // ] + // }, + /** + * EvaDav + * Pros: Fixed weekly pay () + * Cons: + */ { - id: 'https://spicychat.ai/?public_characters_alias%2Fsort%2Fnum_messages_24h%3Adesc[query]={query}&ref=ode2nzn', - weight: 0.5 + name: 'EvaDav', + popupClassification: { + kind: 'in-page-push', + hostnames: ['udzpel.com'], + searchParamPrefixes: IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES + }, + ads: [ + { + id: 'https://udzpel.com/pw/waWQiOjExOTMwMzUsInNpZCI6MTQwNzY1NSwid2lkIjo2ODMzODcsInNyYyI6Mn0=eyJ.js', + weight: 1 + } + ] } ] +function getProviderAds(providers: AdProvider[]): WeightedAd[] { + return providers.reduce((ads, provider) => { + ads.push(...provider.ads) + return ads + }, []) +} + function logAdPopupGuard(event: string, details?: Record) { if (!import.meta.dev) { return @@ -79,13 +229,29 @@ function hostnameMatches(hostname: string, allowedHostnames: string[]): boolean }) } -function findPushAdByScriptId(scriptId: string): PushAd | null { - return PUSH_ADS.find(pushAd => pushAd.id === scriptId) ?? null +function hasMatchingSearchParamPrefix(parsedUrl: URL, searchParamPrefixes: readonly string[]): boolean { + for (const searchParamKey of parsedUrl.searchParams.keys()) { + if (searchParamPrefixes.some(prefix => searchParamKey.startsWith(prefix))) { + return true + } + } + + return false } -function getPopupOpenKind(args: WindowOpenArgs, activePushAd: PushAd | null): PopupOpenKind { - const requestedUrl = getRequestedUrl(args) +function matchesPopupClassification(parsedUrl: URL, popupClassification: PopupClassification): boolean { + if (popupClassification.hostnames && hostnameMatches(parsedUrl.hostname, popupClassification.hostnames)) { + return true + } + + if (popupClassification.searchParamPrefixes && hasMatchingSearchParamPrefix(parsedUrl, popupClassification.searchParamPrefixes)) { + return true + } + + return false +} +function getPopupOpenKind(requestedUrl: string | null): PopupOpenKind { if (!requestedUrl) { return 'popunder' } @@ -93,13 +259,9 @@ function getPopupOpenKind(args: WindowOpenArgs, activePushAd: PushAd | null): Po try { const parsedUrl = new URL(requestedUrl, window.location.href) - if (activePushAd && hostnameMatches(parsedUrl.hostname, activePushAd.inPageOpenHostnames)) { - return 'in-page-push' - } - - for (const searchParamKey of parsedUrl.searchParams.keys()) { - if (searchParamKey.startsWith('inpage.')) { - return 'in-page-push' + for (const provider of PUSH_AD_PROVIDERS) { + if (provider.popupClassification && matchesPopupClassification(parsedUrl, provider.popupClassification)) { + return provider.popupClassification.kind } } } catch { @@ -153,6 +315,7 @@ export default function () { ) { resolvedLastPopupAt = inMemoryLastPopupAt } else { + // Reset invalid or future in-memory values so they do not over-block. lastAdPopupAtInMemory.value = null } } @@ -171,7 +334,7 @@ export default function () { } } } catch { - // Ignore storage failures and use in-memory fallback. + // Ignore storage failures and use in-memory fallback } lastAdPopupAtInMemory.value = resolvedLastPopupAt @@ -185,7 +348,7 @@ export default function () { try { window.localStorage.setItem(AD_LAST_POPUP_AT_STORAGE_KEY, String(at)) } catch { - // Ignore storage failures and keep the in-memory fallback. + // Ignore storage failures and keep the in-memory fallback } } @@ -219,13 +382,13 @@ export default function () { const originalWindowOpen = window.open.bind(window) window.open = (...args: WindowOpenArgs): WindowOpenResult => { - const activePushAd = findPushAdByScriptId(pushScript.value) + const requestedUrl = getRequestedUrl(args) if (shouldBypassNextWindowOpenGuard.value) { shouldBypassNextWindowOpenGuard.value = false logAdPopupGuard('trusted-open-bypass', { - requestedUrl: getRequestedUrl(args) + requestedUrl }) return originalWindowOpen(...args) @@ -235,9 +398,9 @@ export default function () { return originalWindowOpen(...args) } - if (getPopupOpenKind(args, activePushAd) === 'in-page-push') { + if (getPopupOpenKind(requestedUrl) === 'in-page-push') { logAdPopupGuard('allow-in-page-push', { - requestedUrl: getRequestedUrl(args) + requestedUrl }) return originalWindowOpen(...args) @@ -245,7 +408,7 @@ export default function () { if (isAdPopupCapActive()) { logAdPopupGuard('block-capped-popunder', { - requestedUrl: getRequestedUrl(args), + requestedUrl, ...getAdPopupCapLogDetails() }) @@ -255,7 +418,7 @@ export default function () { recordAdPopupOpened() logAdPopupGuard('allow-popunder', { - requestedUrl: getRequestedUrl(args), + requestedUrl, cappedUntil: Date.now() + AD_POPUP_CAP_DURATION_MS }) @@ -265,6 +428,7 @@ export default function () { isPopupGuardInstalled.value = true } + // Stop injecting ad scripts while the 20-minute popup cap is active. if (isAdPopupCapActive()) { logAdPopupGuard('skip-script-injection-while-capped', { popunderScript: popunderScript.value || null, @@ -275,21 +439,27 @@ export default function () { return } + // Once scripts load, guard future popunder opens with the first-party cap. isPopupGuardArmed.value = true + const popunderAds = getProviderAds(POPUNDER_AD_PROVIDERS) + const pushAds = getProviderAds(PUSH_AD_PROVIDERS) + if (!popunderScript.value) { - popunderScript.value = randomWeightedChoice(POPUNDER_ADS) + popunderScript.value = randomWeightedChoice(popunderAds) } if (!pushScript.value) { - pushScript.value = randomWeightedChoice(PUSH_ADS) + pushScript.value = randomWeightedChoice(pushAds) } useHead({ script: [ { src: popunderScript.value, - ...AD_SCRIPT_ATTRIBUTES + ...AD_SCRIPT_ATTRIBUTES, + + // Fix for CORS issues - https://unhead.unjs.io/usage/composables/use-script#referrerpolicy-and-crossorigin }, { src: pushScript.value, @@ -316,8 +486,19 @@ export function openTrustedWindow(...args: WindowOpenArgs): WindowOpenResult { } export function useChatWithAiReferral() { + const chatWithAiReferrals = [ + { + id: 'https://crushon.ai/search?s={query}&ref=zdnmmzy&mist=1', + weight: 0.5 + }, + { + id: 'https://spicychat.ai/?public_characters_alias%2Fsort%2Fnum_messages_24h%3Adesc[query]={query}&ref=ode2nzn', + weight: 0.5 + } + ] + const chatWithAiReferralTemplate = useState('chat-with-ai-referral', () => { - return randomWeightedChoice(CHAT_WITH_AI_REFERRALS) + return randomWeightedChoice(chatWithAiReferrals) }) return { From 94bf27c6213d0f76f21a9f3a688ead3c79e37ec4 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:22:10 -0700 Subject: [PATCH 20/26] test: cover popup guard decisions --- assets/js/ads-popup-guard.ts | 158 +++++++++++++++++++++++++++++ composables/useAdvertisements.ts | 122 +++++++--------------- test/pages/ads-popup-guard.test.ts | 136 +++++++++++++++++++++++++ 3 files changed, 330 insertions(+), 86 deletions(-) create mode 100644 assets/js/ads-popup-guard.ts create mode 100644 test/pages/ads-popup-guard.test.ts diff --git a/assets/js/ads-popup-guard.ts b/assets/js/ads-popup-guard.ts new file mode 100644 index 00000000..8f654954 --- /dev/null +++ b/assets/js/ads-popup-guard.ts @@ -0,0 +1,158 @@ +export const AD_POPUP_CAP_DURATION_MS = 20 * 60 * 1000 +export const IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES = ['inpage.'] as const + +export type PopupOpenKind = 'popunder' | 'in-page-push' +export type PopupClassification = { + kind: PopupOpenKind + hostnames?: string[] + searchParamPrefixes?: readonly string[] +} + +export type AdPopupCapLogDetails = { + lastPopupAt: number | null + cappedUntil: number | null + remainingMs: number | null +} + +export type PopupGuardDecision = { + event: 'allow-in-page-push' | 'allow-popunder' | 'block-capped-popunder' + shouldAllow: boolean + shouldRecordPopupAt: boolean + capLogDetails?: AdPopupCapLogDetails + cappedUntil?: number +} + +export type TrustedPopupBypassDecision = { + shouldBypassCurrentOpen: boolean + nextShouldBypass: boolean +} + +function hostnameMatches(hostname: string, allowedHostnames: string[]): boolean { + return allowedHostnames.some(allowedHostname => { + return hostname === allowedHostname || hostname.endsWith(`.${allowedHostname}`) + }) +} + +function hasMatchingSearchParamPrefix(parsedUrl: URL, searchParamPrefixes: readonly string[]): boolean { + for (const searchParamKey of parsedUrl.searchParams.keys()) { + if (searchParamPrefixes.some(prefix => searchParamKey.startsWith(prefix))) { + return true + } + } + + return false +} + +function matchesPopupClassification(parsedUrl: URL, popupClassification: PopupClassification): boolean { + if (popupClassification.hostnames && hostnameMatches(parsedUrl.hostname, popupClassification.hostnames)) { + return true + } + + if (popupClassification.searchParamPrefixes && hasMatchingSearchParamPrefix(parsedUrl, popupClassification.searchParamPrefixes)) { + return true + } + + return false +} + +export function getTrustedPopupBypassDecision(shouldBypassNextWindowOpenGuard: boolean): TrustedPopupBypassDecision { + return { + shouldBypassCurrentOpen: shouldBypassNextWindowOpenGuard, + nextShouldBypass: false + } +} + +export function getPopupOpenKind( + requestedUrl: string | null, + { + baseUrl, + popupClassifications + }: { + baseUrl: string + popupClassifications: readonly PopupClassification[] + } +): PopupOpenKind { + if (!requestedUrl) { + return 'popunder' + } + + try { + const parsedUrl = new URL(requestedUrl, baseUrl) + + for (const popupClassification of popupClassifications) { + if (matchesPopupClassification(parsedUrl, popupClassification)) { + return popupClassification.kind + } + } + } catch { + // Ignore malformed vendor URLs and keep the default popunder classification. + } + + return 'popunder' +} + +export function isAdPopupCapActive( + lastPopupAt: number | null, + now: number, + capDurationMs = AD_POPUP_CAP_DURATION_MS +): boolean { + return lastPopupAt !== null && now - lastPopupAt < capDurationMs +} + +export function getAdPopupCapLogDetails( + lastPopupAt: number | null, + now: number, + capDurationMs = AD_POPUP_CAP_DURATION_MS +): AdPopupCapLogDetails { + if (lastPopupAt === null) { + return { + lastPopupAt: null, + cappedUntil: null, + remainingMs: null + } + } + + const cappedUntil = lastPopupAt + capDurationMs + + return { + lastPopupAt, + cappedUntil, + remainingMs: Math.max(0, cappedUntil - now) + } +} + +export function getPopupGuardDecision({ + popupOpenKind, + lastPopupAt, + now, + capDurationMs = AD_POPUP_CAP_DURATION_MS +}: { + popupOpenKind: PopupOpenKind + lastPopupAt: number | null + now: number + capDurationMs?: number +}): PopupGuardDecision { + if (popupOpenKind === 'in-page-push') { + return { + event: 'allow-in-page-push', + shouldAllow: true, + shouldRecordPopupAt: false + } + } + + if (isAdPopupCapActive(lastPopupAt, now, capDurationMs)) { + return { + event: 'block-capped-popunder', + shouldAllow: false, + shouldRecordPopupAt: false, + capLogDetails: getAdPopupCapLogDetails(lastPopupAt, now, capDurationMs) + } + } + + return { + event: 'allow-popunder', + shouldAllow: true, + shouldRecordPopupAt: true, + cappedUntil: now + capDurationMs + } +} diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index a16cb938..701595b7 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -1,10 +1,18 @@ import { default as randomWeightedChoice } from 'random-weighted-choice' +import { + AD_POPUP_CAP_DURATION_MS, + IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES, + getAdPopupCapLogDetails as getAdPopupCapLogDetailsPure, + getPopupGuardDecision, + getPopupOpenKind as getPopupOpenKindPure, + getTrustedPopupBypassDecision, + isAdPopupCapActive as isAdPopupCapActivePure, + type PopupClassification +} from '../assets/js/ads-popup-guard' -const AD_POPUP_CAP_DURATION_MS = 20 * 60 * 1000 const AD_LAST_POPUP_AT_STORAGE_KEY = 'ads-last-popup-at' const AD_TRUSTED_WINDOW_OPEN_BYPASS_STATE_KEY = 'ads-trusted-window-open-bypass-next' const INTEGER_TIMESTAMP_REGEX = /^\d+$/ -const IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES = ['inpage.'] as const const AD_SCRIPT_ATTRIBUTES = { async: false, defer: true, @@ -13,12 +21,6 @@ const AD_SCRIPT_ATTRIBUTES = { type WindowOpenArgs = Parameters type WindowOpenResult = ReturnType -type PopupOpenKind = 'popunder' | 'in-page-push' -type PopupClassification = { - kind: PopupOpenKind - hostnames?: string[] - searchParamPrefixes?: readonly string[] -} type WeightedAd = { id: string weight: number @@ -223,53 +225,9 @@ function getRequestedUrl(args: WindowOpenArgs): string | null { return typeof requestedUrl === 'string' ? requestedUrl : null } -function hostnameMatches(hostname: string, allowedHostnames: string[]): boolean { - return allowedHostnames.some(allowedHostname => { - return hostname === allowedHostname || hostname.endsWith(`.${allowedHostname}`) - }) -} - -function hasMatchingSearchParamPrefix(parsedUrl: URL, searchParamPrefixes: readonly string[]): boolean { - for (const searchParamKey of parsedUrl.searchParams.keys()) { - if (searchParamPrefixes.some(prefix => searchParamKey.startsWith(prefix))) { - return true - } - } - - return false -} - -function matchesPopupClassification(parsedUrl: URL, popupClassification: PopupClassification): boolean { - if (popupClassification.hostnames && hostnameMatches(parsedUrl.hostname, popupClassification.hostnames)) { - return true - } - - if (popupClassification.searchParamPrefixes && hasMatchingSearchParamPrefix(parsedUrl, popupClassification.searchParamPrefixes)) { - return true - } - - return false -} - -function getPopupOpenKind(requestedUrl: string | null): PopupOpenKind { - if (!requestedUrl) { - return 'popunder' - } - - try { - const parsedUrl = new URL(requestedUrl, window.location.href) - - for (const provider of PUSH_AD_PROVIDERS) { - if (provider.popupClassification && matchesPopupClassification(parsedUrl, provider.popupClassification)) { - return provider.popupClassification.kind - } - } - } catch { - // Ignore malformed vendor URLs and keep the default popunder classification. - } - - return 'popunder' -} +const PUSH_POPUP_CLASSIFICATIONS: readonly PopupClassification[] = PUSH_AD_PROVIDERS + .map(provider => provider.popupClassification) + .filter((popupClassification): popupClassification is PopupClassification => Boolean(popupClassification)) export default function () { const popunderScript = useState('popunder-script', () => '') @@ -355,27 +313,13 @@ export default function () { function isAdPopupCapActive(now = Date.now()): boolean { const lastPopupAt = getLastAdPopupAt(now) - return lastPopupAt !== null && now - lastPopupAt < AD_POPUP_CAP_DURATION_MS + return isAdPopupCapActivePure(lastPopupAt, now) } function getAdPopupCapLogDetails(now = Date.now()): Record { const lastPopupAt = getLastAdPopupAt(now) - if (lastPopupAt === null) { - return { - lastPopupAt: null, - cappedUntil: null, - remainingMs: null - } - } - - const cappedUntil = lastPopupAt + AD_POPUP_CAP_DURATION_MS - - return { - lastPopupAt, - cappedUntil, - remainingMs: Math.max(0, cappedUntil - now) - } + return getAdPopupCapLogDetailsPure(lastPopupAt, now) } if (!isPopupGuardInstalled.value) { @@ -383,9 +327,10 @@ export default function () { window.open = (...args: WindowOpenArgs): WindowOpenResult => { const requestedUrl = getRequestedUrl(args) + const trustedPopupBypassDecision = getTrustedPopupBypassDecision(shouldBypassNextWindowOpenGuard.value) - if (shouldBypassNextWindowOpenGuard.value) { - shouldBypassNextWindowOpenGuard.value = false + if (trustedPopupBypassDecision.shouldBypassCurrentOpen) { + shouldBypassNextWindowOpenGuard.value = trustedPopupBypassDecision.nextShouldBypass logAdPopupGuard('trusted-open-bypass', { requestedUrl @@ -398,28 +343,33 @@ export default function () { return originalWindowOpen(...args) } - if (getPopupOpenKind(requestedUrl) === 'in-page-push') { - logAdPopupGuard('allow-in-page-push', { - requestedUrl - }) - - return originalWindowOpen(...args) - } + const popupOpenKind = getPopupOpenKindPure(requestedUrl, { + baseUrl: window.location.href, + popupClassifications: PUSH_POPUP_CLASSIFICATIONS + }) + const now = Date.now() + const popupGuardDecision = getPopupGuardDecision({ + popupOpenKind, + lastPopupAt: popupOpenKind === 'in-page-push' ? null : getLastAdPopupAt(now), + now + }) - if (isAdPopupCapActive()) { - logAdPopupGuard('block-capped-popunder', { + if (!popupGuardDecision.shouldAllow) { + logAdPopupGuard(popupGuardDecision.event, { requestedUrl, - ...getAdPopupCapLogDetails() + ...popupGuardDecision.capLogDetails }) return null } - recordAdPopupOpened() + if (popupGuardDecision.shouldRecordPopupAt) { + recordAdPopupOpened(now) + } - logAdPopupGuard('allow-popunder', { + logAdPopupGuard(popupGuardDecision.event, { requestedUrl, - cappedUntil: Date.now() + AD_POPUP_CAP_DURATION_MS + ...(popupGuardDecision.cappedUntil ? { cappedUntil: popupGuardDecision.cappedUntil } : {}) }) return originalWindowOpen(...args) diff --git a/test/pages/ads-popup-guard.test.ts b/test/pages/ads-popup-guard.test.ts new file mode 100644 index 00000000..7d262c48 --- /dev/null +++ b/test/pages/ads-popup-guard.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { + AD_POPUP_CAP_DURATION_MS, + getAdPopupCapLogDetails, + getPopupGuardDecision, + getPopupOpenKind, + getTrustedPopupBypassDecision, + IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES +} from '../../assets/js/ads-popup-guard' + +const PUSH_POPUP_CLASSIFICATIONS = [ + { + kind: 'in-page-push' as const, + hostnames: ['hotbsizovu.today', 'hotsoz.com'], + searchParamPrefixes: IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES + }, + { + kind: 'in-page-push' as const, + hostnames: ['udzpel.com'], + searchParamPrefixes: IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES + } +] + +describe('popup guard', () => { + it('allows the first popunder attempt and records the cap decision path', () => { + const now = 1_710_000_000_000 + const popupOpenKind = getPopupOpenKind('https://bundlemoviepumice.com/test', { + baseUrl: 'https://r34.app/', + popupClassifications: PUSH_POPUP_CLASSIFICATIONS + }) + + const decision = getPopupGuardDecision({ + popupOpenKind, + lastPopupAt: null, + now + }) + + expect(decision).toEqual({ + event: 'allow-popunder', + shouldAllow: true, + shouldRecordPopupAt: true, + cappedUntil: now + AD_POPUP_CAP_DURATION_MS + }) + }) + + it('blocks a second popunder attempt within 20 minutes', () => { + const now = 1_710_000_000_000 + const fiveMinutesAgo = now - (5 * 60 * 1000) + + const decision = getPopupGuardDecision({ + popupOpenKind: 'popunder', + lastPopupAt: fiveMinutesAgo, + now + }) + + expect(decision.event).toBe('block-capped-popunder') + expect(decision.shouldAllow).toBe(false) + expect(decision.shouldRecordPopupAt).toBe(false) + expect(decision.capLogDetails).toEqual({ + lastPopupAt: fiveMinutesAgo, + cappedUntil: fiveMinutesAgo + AD_POPUP_CAP_DURATION_MS, + remainingMs: AD_POPUP_CAP_DURATION_MS - (5 * 60 * 1000) + }) + }) + + it('treats push provider URLs as in-page-push and exempts them from cap recording', () => { + const now = 1_710_000_000_000 + const popupOpenKind = getPopupOpenKind('https://hotsoz.com/wnclcm?inpage.campaign=foo', { + baseUrl: 'https://r34.app/', + popupClassifications: PUSH_POPUP_CLASSIFICATIONS + }) + + const decision = getPopupGuardDecision({ + popupOpenKind, + lastPopupAt: now - 60_000, + now + }) + + expect(popupOpenKind).toBe('in-page-push') + expect(decision).toEqual({ + event: 'allow-in-page-push', + shouldAllow: true, + shouldRecordPopupAt: false + }) + }) + + it('returns trusted bypass decision path for guarded window.open', () => { + expect(getTrustedPopupBypassDecision(true)).toEqual({ + shouldBypassCurrentOpen: true, + nextShouldBypass: false + }) + + expect(getTrustedPopupBypassDecision(false)).toEqual({ + shouldBypassCurrentOpen: false, + nextShouldBypass: false + }) + }) + + it('allows popunder after cap expires at 20 minutes', () => { + const now = 1_710_000_000_000 + const expiredLastPopupAt = now - AD_POPUP_CAP_DURATION_MS - 1 + + const decision = getPopupGuardDecision({ + popupOpenKind: 'popunder', + lastPopupAt: expiredLastPopupAt, + now + }) + + expect(decision).toEqual({ + event: 'allow-popunder', + shouldAllow: true, + shouldRecordPopupAt: true, + cappedUntil: now + AD_POPUP_CAP_DURATION_MS + }) + + expect(getAdPopupCapLogDetails(expiredLastPopupAt, now)).toEqual({ + lastPopupAt: expiredLastPopupAt, + cappedUntil: expiredLastPopupAt + AD_POPUP_CAP_DURATION_MS, + remainingMs: 0 + }) + }) + + it('classifies provider URL as in-page-push by hostname or param prefix', () => { + const byHostname = getPopupOpenKind('https://hotbsizovu.today/process.js?id=1', { + baseUrl: 'https://r34.app/', + popupClassifications: PUSH_POPUP_CLASSIFICATIONS + }) + const byQueryParamPrefix = getPopupOpenKind('https://unknown.example/path?inpage.foo=bar', { + baseUrl: 'https://r34.app/', + popupClassifications: PUSH_POPUP_CLASSIFICATIONS + }) + + expect(byHostname).toBe('in-page-push') + expect(byQueryParamPrefix).toBe('in-page-push') + }) +}) From 92c9fda938b0868c5701b55fdb8de2cfd4cf886b Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:36:27 -0700 Subject: [PATCH 21/26] fix: correct popunder provider URL --- composables/useAdvertisements.ts | 2 +- test/pages/ads-popup-guard.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index 701595b7..fd679449 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -69,7 +69,7 @@ const POPUNDER_AD_PROVIDERS: AdProvider[] = [ name: 'HilltopAds', ads: [ { - id: 'https:////ellipticaltrack.com/c.D/9v6/bW2/5aleSRW/Qj9SNojrA/zWMxTuk_zvNoiJ0S2kMgDBMux_OXTCMU3Z', + id: 'https://ellipticaltrack.com/c.D/9v6/bW2/5aleSRW/Qj9SNojrA/zWMxTuk_zvNoiJ0S2kMgDBMux_OXTCMU3Z', weight: 1 } ] diff --git a/test/pages/ads-popup-guard.test.ts b/test/pages/ads-popup-guard.test.ts index 7d262c48..2d3b201e 100644 --- a/test/pages/ads-popup-guard.test.ts +++ b/test/pages/ads-popup-guard.test.ts @@ -43,7 +43,7 @@ describe('popup guard', () => { }) }) - it('blocks a second popunder attempt within 20 minutes', () => { + it('blocks a second popunder attempt within the active cap window', () => { const now = 1_710_000_000_000 const fiveMinutesAgo = now - (5 * 60 * 1000) @@ -96,7 +96,7 @@ describe('popup guard', () => { }) }) - it('allows popunder after cap expires at 20 minutes', () => { + it('allows popunder after the active cap expires', () => { const now = 1_710_000_000_000 const expiredLastPopupAt = now - AD_POPUP_CAP_DURATION_MS - 1 From f42c1313d02f6b32d0c8ceeaeed72b7529ac608e Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:44:09 -0700 Subject: [PATCH 22/26] test: assert required popup cap duration --- test/pages/ads-popup-guard.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/test/pages/ads-popup-guard.test.ts b/test/pages/ads-popup-guard.test.ts index 2d3b201e..24066f5d 100644 --- a/test/pages/ads-popup-guard.test.ts +++ b/test/pages/ads-popup-guard.test.ts @@ -8,6 +8,8 @@ import { IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES } from '../../assets/js/ads-popup-guard' +const REQUIRED_AD_POPUP_CAP_DURATION_MS = 20 * 60 * 1000 + const PUSH_POPUP_CLASSIFICATIONS = [ { kind: 'in-page-push' as const, @@ -22,6 +24,10 @@ const PUSH_POPUP_CLASSIFICATIONS = [ ] describe('popup guard', () => { + it('keeps the popup cap at the required 20 minutes', () => { + expect(AD_POPUP_CAP_DURATION_MS).toBe(REQUIRED_AD_POPUP_CAP_DURATION_MS) + }) + it('allows the first popunder attempt and records the cap decision path', () => { const now = 1_710_000_000_000 const popupOpenKind = getPopupOpenKind('https://bundlemoviepumice.com/test', { @@ -39,7 +45,7 @@ describe('popup guard', () => { event: 'allow-popunder', shouldAllow: true, shouldRecordPopupAt: true, - cappedUntil: now + AD_POPUP_CAP_DURATION_MS + cappedUntil: now + REQUIRED_AD_POPUP_CAP_DURATION_MS }) }) @@ -58,8 +64,8 @@ describe('popup guard', () => { expect(decision.shouldRecordPopupAt).toBe(false) expect(decision.capLogDetails).toEqual({ lastPopupAt: fiveMinutesAgo, - cappedUntil: fiveMinutesAgo + AD_POPUP_CAP_DURATION_MS, - remainingMs: AD_POPUP_CAP_DURATION_MS - (5 * 60 * 1000) + cappedUntil: fiveMinutesAgo + REQUIRED_AD_POPUP_CAP_DURATION_MS, + remainingMs: REQUIRED_AD_POPUP_CAP_DURATION_MS - (5 * 60 * 1000) }) }) @@ -98,7 +104,7 @@ describe('popup guard', () => { it('allows popunder after the active cap expires', () => { const now = 1_710_000_000_000 - const expiredLastPopupAt = now - AD_POPUP_CAP_DURATION_MS - 1 + const expiredLastPopupAt = now - REQUIRED_AD_POPUP_CAP_DURATION_MS - 1 const decision = getPopupGuardDecision({ popupOpenKind: 'popunder', @@ -110,12 +116,12 @@ describe('popup guard', () => { event: 'allow-popunder', shouldAllow: true, shouldRecordPopupAt: true, - cappedUntil: now + AD_POPUP_CAP_DURATION_MS + cappedUntil: now + REQUIRED_AD_POPUP_CAP_DURATION_MS }) expect(getAdPopupCapLogDetails(expiredLastPopupAt, now)).toEqual({ lastPopupAt: expiredLastPopupAt, - cappedUntil: expiredLastPopupAt + AD_POPUP_CAP_DURATION_MS, + cappedUntil: expiredLastPopupAt + REQUIRED_AD_POPUP_CAP_DURATION_MS, remainingMs: 0 }) }) From 3e88a926e957d200cfd1f0ef4c114314f6025354 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:25:53 -0700 Subject: [PATCH 23/26] fix: abort blocked ad popup fallbacks --- composables/useAdvertisements.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index fd679449..8d4b9417 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -225,6 +225,10 @@ function getRequestedUrl(args: WindowOpenArgs): string | null { return typeof requestedUrl === 'string' ? requestedUrl : null } +function createBlockedAdPopupError(reason: 'frequency-cap' | 'browser-blocked'): Error { + return new Error(`Ad popup blocked: ${reason}`) +} + const PUSH_POPUP_CLASSIFICATIONS: readonly PopupClassification[] = PUSH_AD_PROVIDERS .map(provider => provider.popupClassification) .filter((popupClassification): popupClassification is PopupClassification => Boolean(popupClassification)) @@ -360,7 +364,7 @@ export default function () { ...popupGuardDecision.capLogDetails }) - return null + throw createBlockedAdPopupError('frequency-cap') } if (popupGuardDecision.shouldRecordPopupAt) { @@ -372,7 +376,13 @@ export default function () { ...(popupGuardDecision.cappedUntil ? { cappedUntil: popupGuardDecision.cappedUntil } : {}) }) - return originalWindowOpen(...args) + const popupHandle = originalWindowOpen(...args) + + if (popupGuardDecision.event === 'allow-popunder' && popupHandle === null) { + throw createBlockedAdPopupError('browser-blocked') + } + + return popupHandle } isPopupGuardInstalled.value = true From 6b4ffbb49cd5388cf0466e6c982e9d0abe318481 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:10:55 -0700 Subject: [PATCH 24/26] test: cover popup guard browser flow --- test/pages/ads-popup-guard.browser.test.ts | 273 +++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 test/pages/ads-popup-guard.browser.test.ts diff --git a/test/pages/ads-popup-guard.browser.test.ts b/test/pages/ads-popup-guard.browser.test.ts new file mode 100644 index 00000000..c3feb7d2 --- /dev/null +++ b/test/pages/ads-popup-guard.browser.test.ts @@ -0,0 +1,273 @@ +import { createPage, setup, url } from '@nuxt/test-utils' +import { describe, expect, it } from 'vitest' +import type { Page } from 'playwright-core' +import { defaultBrowserOptions } from '../helper' + +const AD_LAST_POPUP_AT_STORAGE_KEY = 'ads-last-popup-at' +const AD_POPUP_CAP_DURATION_MS = 20 * 60 * 1000 +const FORCED_AD_RANDOM_VALUE = 0.9 +const INTERCEPTED_POPUNDER_URL = 'https://intercepted-ad.test/popunder' +const INTERCEPTED_FALLBACK_URL = 'https://intercepted-ad.test/fallback' + +const INTERCEPTED_DYNAMIC_POPUNDER_SCRIPT = ` +(() => { + if (window.__dynamicPopupAdInstalled) { + return + } + + window.__dynamicPopupAdInstalled = true + + document.addEventListener('click', () => { + const popup = window.open('${INTERCEPTED_POPUNDER_URL}', '_blank') + + if (!popup) { + window.location.href = '${INTERCEPTED_FALLBACK_URL}' + } + }, true) +})() +` + +type RequestCounts = { + popunderWrapper: number + popunderRemote: number + pushRemote: number + dynamicPopunderRemote: number + unexpectedPopunderRemote: number + remoteDocuments: number +} + +async function waitFor(predicate: () => boolean, timeoutMs = 10_000) { + const startedAt = Date.now() + + while (!predicate()) { + if (Date.now() - startedAt >= timeoutMs) { + throw new Error('Timed out waiting for expected browser-side condition') + } + + await new Promise(resolve => setTimeout(resolve, 50)) + } +} + +async function installRealAdRoutes(page: Page, appOrigin: string): Promise { + const requestCounts: RequestCounts = { + popunderWrapper: 0, + popunderRemote: 0, + pushRemote: 0, + dynamicPopunderRemote: 0, + unexpectedPopunderRemote: 0, + remoteDocuments: 0 + } + + await page.context().route('**/*', async (route) => { + const request = route.request() + const requestUrl = new URL(request.url()) + const isLocalDocument = request.resourceType() === 'document' && requestUrl.origin === appOrigin + const isLocalOrigin = requestUrl.origin === appOrigin + + if (request.resourceType() === 'document' && !isLocalDocument) { + requestCounts.remoteDocuments += 1 + + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: 'Intercepted popuppopup' + }) + + return + } + + if (request.resourceType() === 'script' && !isLocalOrigin) { + requestCounts.dynamicPopunderRemote += 1 + + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: INTERCEPTED_DYNAMIC_POPUNDER_SCRIPT + }) + + return + } + + await route.continue() + }) + + await page.route('**/js/popunder2.js*', async (route) => { + requestCounts.popunderWrapper += 1 + await route.continue() + }) + + await page.route('**bundlemoviepumice.com/on.js', async (route) => { + requestCounts.popunderRemote += 1 + + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: 'window.__bundlemoviepumiceLoaded = true;' + }) + }) + + await page.route('**ellipticaltrack.com/**', async (route) => { + requestCounts.unexpectedPopunderRemote += 1 + + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: '' + }) + }) + + await page.route('**udzpel.com/**', async (route) => { + requestCounts.pushRemote += 1 + + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: 'window.__pushVendorLoaded = true;' + }) + }) + + await page.route('**hotbsizovu.today/**', async (route) => { + requestCounts.pushRemote += 1 + + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: 'window.__pushVendorLoaded = true;' + }) + }) + + await page.route('**hotsoz.com/**', async (route) => { + requestCounts.pushRemote += 1 + + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: 'Intercepted push popuppush' + }) + }) + + return requestCounts +} + +async function preparePage() { + const page = await createPage('/') + const appOrigin = new URL(page.url()).origin + const requestCounts = await installRealAdRoutes(page, appOrigin) + + await page.addInitScript(({ forcedRandomValue }) => { + Math.random = () => forcedRandomValue + }, { + forcedRandomValue: FORCED_AD_RANDOM_VALUE + }) + + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey) + }, AD_LAST_POPUP_AT_STORAGE_KEY) + + await page.reload() + await page.waitForSelector('h1') + + return { + page, + appOrigin, + requestCounts + } +} + +async function armAdvertisements(page: Page, requestCounts: RequestCounts, expectScriptInstall = true) { + await page.locator('h1').click() + + if (expectScriptInstall) { + await waitFor(() => { + return requestCounts.popunderWrapper > 0 + && requestCounts.popunderRemote > 0 + && requestCounts.dynamicPopunderRemote > 0 + }) + } +} + +describe('popup guard browser flow', async () => { + await setup({ + browser: true, + browserOptions: defaultBrowserOptions + }) + + it('uses the real popunder wrapper, blocks capped repeats, skips reinjection while capped, and allows again after expiry', async () => { + const { page, appOrigin, requestCounts } = await preparePage() + + await armAdvertisements(page, requestCounts) + + expect(requestCounts.popunderWrapper).toBeGreaterThan(0) + expect(requestCounts.popunderRemote).toBeGreaterThan(0) + expect(requestCounts.dynamicPopunderRemote).toBeGreaterThan(0) + expect(requestCounts.unexpectedPopunderRemote).toBe(0) + + const firstPopupPromise = page.waitForEvent('popup') + + await page.locator('h1').click() + + const firstPopup = await firstPopupPromise + await firstPopup.close() + + expect(await page.evaluate((storageKey) => { + return window.localStorage.getItem(storageKey) + }, AD_LAST_POPUP_AT_STORAGE_KEY)).toMatch(/^\d+$/) + expect(new URL(page.url()).origin).toBe(appOrigin) + + const remoteDocumentsBeforeBlockedClick = requestCounts.remoteDocuments + const blockedPopupPromise = page.waitForEvent('popup', { timeout: 1_000 }).catch(() => null) + const blockedErrorPromise = page.waitForEvent('pageerror', { timeout: 1_000 }).catch(() => null) + + await page.locator('h1').click() + + const [blockedPopup, blockedError] = await Promise.all([blockedPopupPromise, blockedErrorPromise]) + + expect(blockedPopup === null).toBe(true) + expect(blockedError?.message).toContain('Ad popup blocked: frequency-cap') + expect(new URL(page.url()).origin).toBe(appOrigin) + expect(requestCounts.remoteDocuments).toBe(remoteDocumentsBeforeBlockedClick) + + await page.reload() + await page.waitForSelector('h1') + + const reloadRemoteDocumentsBeforeBlockedClick = requestCounts.remoteDocuments + const reloadBlockedPopupPromise = page.waitForEvent('popup', { timeout: 1_000 }).catch(() => null) + + await page.locator('h1').click() + + const reloadBlockedPopup = await reloadBlockedPopupPromise + + expect(reloadBlockedPopup === null).toBe(true) + expect(new URL(page.url()).origin).toBe(appOrigin) + expect(requestCounts.remoteDocuments).toBe(reloadRemoteDocumentsBeforeBlockedClick) + + await page.evaluate(({ storageKey, expiredAt }) => { + window.localStorage.setItem(storageKey, String(expiredAt)) + }, { + storageKey: AD_LAST_POPUP_AT_STORAGE_KEY, + expiredAt: Date.now() - AD_POPUP_CAP_DURATION_MS - 60_000 + }) + + await page.reload() + await page.waitForSelector('h1') + + const popunderWrapperRequestsBeforeExpiredInteraction = requestCounts.popunderWrapper + const popunderRemoteRequestsBeforeExpiredInteraction = requestCounts.popunderRemote + + await page.locator('h1').click() + await page.locator('h1').click() + + expect(requestCounts.popunderWrapper).toBeGreaterThan(popunderWrapperRequestsBeforeExpiredInteraction) + expect(requestCounts.popunderRemote).toBeGreaterThan(popunderRemoteRequestsBeforeExpiredInteraction) + + const expiredPopupPromise = page.waitForEvent('popup') + + await page.locator('h1').click() + + const expiredPopup = await expiredPopupPromise + await expiredPopup.close() + + expect(new URL(page.url()).origin).toBe(appOrigin) + expect(page.url()).not.toContain(INTERCEPTED_FALLBACK_URL) + }, 60_000) +}) From 38697f03b3bdee016f55f97b8cff0cdb6102ffa2 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:47:45 -0700 Subject: [PATCH 25/26] test: tighten capped popup browser assertions --- test/pages/ads-popup-guard.browser.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/pages/ads-popup-guard.browser.test.ts b/test/pages/ads-popup-guard.browser.test.ts index c3feb7d2..c4ec82d4 100644 --- a/test/pages/ads-popup-guard.browser.test.ts +++ b/test/pages/ads-popup-guard.browser.test.ts @@ -214,6 +214,9 @@ describe('popup guard browser flow', async () => { }, AD_LAST_POPUP_AT_STORAGE_KEY)).toMatch(/^\d+$/) expect(new URL(page.url()).origin).toBe(appOrigin) + const popunderWrapperBeforeBlockedClick = requestCounts.popunderWrapper + const popunderRemoteBeforeBlockedClick = requestCounts.popunderRemote + const dynamicPopunderRemoteBeforeBlockedClick = requestCounts.dynamicPopunderRemote const remoteDocumentsBeforeBlockedClick = requestCounts.remoteDocuments const blockedPopupPromise = page.waitForEvent('popup', { timeout: 1_000 }).catch(() => null) const blockedErrorPromise = page.waitForEvent('pageerror', { timeout: 1_000 }).catch(() => null) @@ -225,6 +228,9 @@ describe('popup guard browser flow', async () => { expect(blockedPopup === null).toBe(true) expect(blockedError?.message).toContain('Ad popup blocked: frequency-cap') expect(new URL(page.url()).origin).toBe(appOrigin) + expect(requestCounts.popunderWrapper).toBe(popunderWrapperBeforeBlockedClick) + expect(requestCounts.popunderRemote).toBe(popunderRemoteBeforeBlockedClick) + expect(requestCounts.dynamicPopunderRemote).toBe(dynamicPopunderRemoteBeforeBlockedClick) expect(requestCounts.remoteDocuments).toBe(remoteDocumentsBeforeBlockedClick) await page.reload() From 031a706c4343645687b0418acd6723b91c205b21 Mon Sep 17 00:00:00 2001 From: AlejandroAkbal <37181533+AlejandroAkbal@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:58:15 -0700 Subject: [PATCH 26/26] refactor: simplify ad provider flattening --- composables/useAdvertisements.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index 8d4b9417..887b4ec3 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -202,10 +202,7 @@ const PUSH_AD_PROVIDERS: AdProvider[] = [ ] function getProviderAds(providers: AdProvider[]): WeightedAd[] { - return providers.reduce((ads, provider) => { - ads.push(...provider.ads) - return ads - }, []) + return providers.flatMap(provider => provider.ads) } function logAdPopupGuard(event: string, details?: Record) { @@ -230,8 +227,7 @@ function createBlockedAdPopupError(reason: 'frequency-cap' | 'browser-blocked'): } const PUSH_POPUP_CLASSIFICATIONS: readonly PopupClassification[] = PUSH_AD_PROVIDERS - .map(provider => provider.popupClassification) - .filter((popupClassification): popupClassification is PopupClassification => Boolean(popupClassification)) + .flatMap(provider => provider.popupClassification ? [provider.popupClassification] : []) export default function () { const popunderScript = useState('popunder-script', () => '')