From 5e9d02fbfbb713def7cac3c5171633d8b0212657 Mon Sep 17 00:00:00 2001 From: madblex Date: Thu, 6 Nov 2025 17:41:18 +0100 Subject: [PATCH 01/13] feat: web interference detection prototype --- injected/src/error-utils.js | 16 ++ injected/src/features/broker-protection.js | 20 +++ .../features/web-interference-detection.js | 53 ++++++ .../web-interference-detection/README.md | 165 ++++++++++++++++++ .../default-config.js | 74 ++++++++ .../detections/bot-detection.js | 69 ++++++++ .../detections/detection-base.js | 74 ++++++++ .../detections/fraud-detection.js | 50 ++++++ .../detections/youtube-ads-detection.js | 89 ++++++++++ .../detector-service.js | 81 +++++++++ .../types/api.types.js | 14 ++ .../types/detection.types.js | 124 +++++++++++++ .../utils/detection-utils.js | 117 +++++++++++++ .../utils/result-factory.js | 17 ++ 14 files changed, 963 insertions(+) create mode 100644 injected/src/error-utils.js create mode 100644 injected/src/features/web-interference-detection.js create mode 100644 injected/src/services/web-interference-detection/README.md create mode 100644 injected/src/services/web-interference-detection/default-config.js create mode 100644 injected/src/services/web-interference-detection/detections/bot-detection.js create mode 100644 injected/src/services/web-interference-detection/detections/detection-base.js create mode 100644 injected/src/services/web-interference-detection/detections/fraud-detection.js create mode 100644 injected/src/services/web-interference-detection/detections/youtube-ads-detection.js create mode 100644 injected/src/services/web-interference-detection/detector-service.js create mode 100644 injected/src/services/web-interference-detection/types/api.types.js create mode 100644 injected/src/services/web-interference-detection/types/detection.types.js create mode 100644 injected/src/services/web-interference-detection/utils/detection-utils.js create mode 100644 injected/src/services/web-interference-detection/utils/result-factory.js diff --git a/injected/src/error-utils.js b/injected/src/error-utils.js new file mode 100644 index 0000000000..4ee3d300b5 --- /dev/null +++ b/injected/src/error-utils.js @@ -0,0 +1,16 @@ +/** + * @template T + * @param {function(): T} fn - The function to call safely + * @param {object} [options] + * @param {string} [options.errorMessage] - The error message to log + * @returns {T|null} - The result of the function call, or null if an error occurred + */ +export function safeCall(fn, { errorMessage } = {}) { + try { + return fn(); + } catch (e) { + console.error(errorMessage ?? '[safeCall] Error:', e); + // TODO fire pixel + return null; + } +} diff --git a/injected/src/features/broker-protection.js b/injected/src/features/broker-protection.js index 05294dc88e..c30ae0f741 100644 --- a/injected/src/features/broker-protection.js +++ b/injected/src/features/broker-protection.js @@ -1,7 +1,13 @@ +/** + * @typedef {import('../services/web-interference-detection/types/api.types.js').InterferenceDetectionRequest} InterferenceDetectionRequest + */ + import ContentFeature from '../content-feature.js'; import { execute } from './broker-protection/execute.js'; import { retry } from '../timer-utils.js'; import { ErrorResponse } from './broker-protection/types.js'; +import { createWebInterferenceService } from '../services/web-interference-detection/detector-service.js'; +import { DEFAULT_INTERFERENCE_CONFIG } from '../services/web-interference-detection/default-config.js'; export class ActionExecutorBase extends ContentFeature { /** @@ -87,10 +93,24 @@ export class ActionExecutorBase extends ContentFeature { */ export default class BrokerProtection extends ActionExecutorBase { init() { + const interferenceConfig = this.getFeatureAttr('interferenceTypes', DEFAULT_INTERFERENCE_CONFIG); + const service = createWebInterferenceService({ interferenceConfig }); + this.messaging.subscribe('onActionReceived', async (/** @type {any} */ params) => { const { action, data } = params.state; return await this.processActionAndNotify(action, data); }); + + this.messaging.subscribe('detectInterference', (/** @type {InterferenceDetectionRequest} */ request) => { + try { + const detectionResults = service.detect(request); + console.log('[BrokerProtection] Detection results:', detectionResults); + return this.messaging.notify('interferenceDetected', detectionResults); + } catch (error) { + console.error('[BrokerProtection] Error detecting interference:', error); + return this.messaging.notify('interferenceDetectionError', { error: error.toString() }); + } + }); } /** diff --git a/injected/src/features/web-interference-detection.js b/injected/src/features/web-interference-detection.js new file mode 100644 index 0000000000..4a61275bce --- /dev/null +++ b/injected/src/features/web-interference-detection.js @@ -0,0 +1,53 @@ +/** + * @typedef {import('../services/web-interference-detection/types/api.types.js').InterferenceDetectionRequest} InterferenceDetectionRequest + */ + +import ContentFeature from '../content-feature.js'; +import { createWebInterferenceService } from '../services/web-interference-detection/detector-service.js'; +import { DEFAULT_INTERFERENCE_CONFIG } from '../services/web-interference-detection/default-config.js'; + +export default class WebInterferenceDetection extends ContentFeature { + init() { + const featureEnabled = this.getFeatureSettingEnabled('state'); + if (!featureEnabled) { + return; + } + const interferenceConfig = this.getFeatureAttr('interferenceTypes', DEFAULT_INTERFERENCE_CONFIG); + const service = createWebInterferenceService({ + interferenceConfig, + onDetectionChange: (result) => { + this.messaging.notify('interferenceChanged', result); + }, + }); + + /** + * Example: One-time detection + * Native -> CSS: Call detectInterference + * CSS -> Native: Return interferenceDetected with immediate results + */ + this.messaging.subscribe('detectInterference', (/** @type {InterferenceDetectionRequest} */ request) => { + try { + const detectionResults = service.detect(request); + return this.messaging.notify('interferenceDetected', detectionResults); + } catch (error) { + console.error('[WebInterferenceDetection] Detection failed:', error); + return this.messaging.notify('interferenceDetectionError', { error: error.toString() }); + } + }); + + /** + * Example: Continuous monitoring + * Native -> CSS: Call startInterferenceMonitoring + * CSS -> Native: Return monitoringStarted with initial results + * CSS -> Native: Send interferenceChanged whenever detection changes (for types with observeDOMChanges: true) + */ + this.messaging.subscribe('startInterferenceMonitoring', (/** @type {InterferenceDetectionRequest} */ request) => { + try { + service.detect(request); + } catch (error) { + console.error('[WebInterferenceDetection] Monitoring failed:', error); + return this.messaging.notify('interferenceDetectionError', { error: error.toString() }); + } + }); + } +} diff --git a/injected/src/services/web-interference-detection/README.md b/injected/src/services/web-interference-detection/README.md new file mode 100644 index 0000000000..11a31a5593 --- /dev/null +++ b/injected/src/services/web-interference-detection/README.md @@ -0,0 +1,165 @@ +# Web Interference Detection Service + +Detects bot challenges, anti-fraud warnings, and video ads on web pages. Supports on-demand detection and continuous DOM monitoring based on configuration. + +## Architecture + +```mermaid +graph TB + subgraph C-S-S + Feature[WebInterferenceDetection
ContentFeature] + Service[WebInterferenceDetectionService] + + subgraph Detectors["Detection Classes"] + Bot[BotDetection] + Fraud[FraudDetection] + YouTube[YouTubeAdsDetection] + end + + Base[DetectionBase] + Utils[detection-utils] + end + + NativeApps <-->|"messaging
(detectInterference,
startInterferenceMonitoring)"| Feature + Feature --> Service + Service --> Bot + Service --> Fraud + Service --> YouTube + YouTube --> Base + Base -.MutationObserver, polling.-> Base + Bot --> Utils + Fraud --> Utils + YouTube --> Utils +``` + +### Folder Structure + +``` +web-interference-detection/ +├── detector-service.js # Main service orchestrator +├── default-config.js # Default configuration +├── detections/ +│ ├── bot-detection.js # CAPTCHA detection +│ ├── fraud-detection.js # Anti-fraud warnings +│ ├── youtube-ads-detection.js # Video ad detection +│ └── detection-base.js # Base class with observers +├── utils/ +│ ├── detection-utils.js # Shared utilities +│ └── result-factory.js # Result creation +└── types/ + ├── detection.types.js # Type definitions + └── api.types.js # API types +``` + +## Usage + +### On-Demand Detection + +```javascript +import { createWebInterferenceService } from './detector-service.js'; + +const service = createWebInterferenceService({ + interferenceConfig, + onDetectionChange: null, +}); + +const results = service.detect({ types: ['botDetection'] }); +``` + +**Full Response Example:** + +```javascript +{ + botDetection: { + detected: true, + interferenceType: 'botDetection', + results: [ + { + detected: true, + vendor: 'cloudflare', + challengeType: 'cloudflareTurnstile', + challengeStatus: 'visible' + } + ], + timestamp: 1699283942123 + } +} +``` + +### Continuous Monitoring + +```javascript +const service = createWebInterferenceService({ + interferenceConfig, + onDetectionChange: (result) => { + // Called when detection state changes (e.g., ad starts/stops) + console.log('Interference changed:', result); + }, +}); + +service.detect({ types: ['youtubeAds'] }); +// Service will monitor DOM changes and invoke callback + +service.cleanup(); // Stop observers and cleanup +``` + +## Configuration Structure + +```javascript +{ + settings: { + botDetection: { + cloudflareTurnstile: { + state: 'enabled', + vendor: 'cloudflare', + selectors: ['.cf-turnstile', 'script[src*="challenges.cloudflare.com"]'], + windowProperties: ['turnstile'], + statusSelectors: [ + { + status: 'solved', + selectors: ['[data-state="success"]'] + }, + { + status: 'failed', + selectors: ['[data-state="error"]'] + } + ], + observeDOMChanges: false + } + }, + youtubeAds: { + rootSelector: '#movie_player', + watchAttributes: ['class', 'style', 'aria-label'], + selectors: ['.ytp-ad-text', '.ytp-ad-skip-button', '.ytp-ad-preview-text'], + adClasses: ['ad-showing', 'ad-interrupting'], + textPatterns: ['skip ad', 'sponsored'], + textSources: ['innerText', 'ariaLabel'], + pollInterval: 2000, + rerootInterval: 1000, + observeDOMChanges: true + }, + } +} +``` + +## Key Concepts + +**Interference Types**: + +- `botDetection` for bor detection mechanisms (captchas, cloudflare, etc) +- `fraudDetection` for anti-fraud warnings +- `youtubeAds` for youtube video ads + +**Config-Driven Behavior**: Each interference type has independent configuration. Set `observeDOMChanges: true` to enable continuous monitoring for that specific interference type. + +**Service Lifecycle**: The service is created once during feature initialization and reused throughout the page lifecycle. Call `detect(request)` with specific interference types whenever detection is needed. Call `cleanup()` when the page is unloaded to stop all active observers and polling. + +**Messaging**: Use `detectInterference` for on-demand checks. Use `startInterferenceMonitoring` for continuous observation with callbacks. + +## Caveats + +⚠️ **MutationObserver/Polling Reuse**: Current implementation creates new observers for each `detect()` call if `observeDOMChanges: true`. Multiple detections may create redundant observers and affect performance. Future iterations should consider one or more of: + +- Observer pooling/reuse across detections +- Debouncing detection calls +- Centralized DOM observation with multiplexed callbacks diff --git a/injected/src/services/web-interference-detection/default-config.js b/injected/src/services/web-interference-detection/default-config.js new file mode 100644 index 0000000000..1e307dab74 --- /dev/null +++ b/injected/src/services/web-interference-detection/default-config.js @@ -0,0 +1,74 @@ +/** + * @typedef {import('./types/detection.types.js').InterferenceConfig} InterferenceConfig + */ + +/** + * @type {InterferenceConfig} + */ +export const DEFAULT_INTERFERENCE_CONFIG = Object.freeze( + /** @type {InterferenceConfig} */ ({ + settings: { + botDetection: { + cloudflareTurnstile: { + state: 'enabled', + vendor: 'cloudflare', + selectors: ['.cf-turnstile', 'script[src*="challenges.cloudflare.com"]'], + windowProperties: ['turnstile'], + statusSelectors: [ + { + status: 'solved', + selectors: ['[data-state="success"]'], + }, + { + status: 'failed', + selectors: ['[data-state="error"]'], + }, + ], + }, + cloudflareChallengePage: { + state: 'enabled', + vendor: 'cloudflare', + selectors: ['#challenge-form', '.cf-browser-verification', '#cf-wrapper', 'script[src*="challenges.cloudflare.com"]'], + windowProperties: ['_cf_chl_opt', '__CF$cv$params', 'cfjsd'], + }, + hcaptcha: { + state: 'enabled', + vendor: 'hcaptcha', + selectors: [ + '.h-captcha', + '[data-hcaptcha-widget-id]', + 'script[src*="hcaptcha.com"]', + 'script[src*="assets.hcaptcha.com"]', + ], + windowProperties: ['hcaptcha'], + }, + }, + fraudDetection: { + phishingWarning: { + state: 'enabled', + type: 'phishing', + selectors: ['.warning-banner', '#security-alert'], + textPatterns: ['suspicious.*activity', 'unusual.*login', 'verify.*account'], + textSources: ['innerText'], + }, + accountSuspension: { + state: 'enabled', + type: 'suspension', + selectors: ['.account-suspended', '#suspension-notice'], + textPatterns: ['account.*suspended', 'access.*restricted'], + textSources: ['innerText'], + }, + }, + youtubeAds: { + rootSelector: '#movie_player', + watchAttributes: ['class', 'style', 'aria-label'], + selectors: ['.ytp-ad-text', '.ytp-ad-skip-button', '.ytp-ad-preview-text'], + adClasses: ['ad-showing', 'ad-interrupting'], + textPatterns: ['skip ad', 'sponsored'], + textSources: ['innerText', 'ariaLabel'], + pollInterval: 2000, + rerootInterval: 1000, + }, + }, + }), +); diff --git a/injected/src/services/web-interference-detection/detections/bot-detection.js b/injected/src/services/web-interference-detection/detections/bot-detection.js new file mode 100644 index 0000000000..1b1e754926 --- /dev/null +++ b/injected/src/services/web-interference-detection/detections/bot-detection.js @@ -0,0 +1,69 @@ +/** + * @typedef {import('../types/detection.types.js').BotDetectionConfig} BotDetectionConfig + * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult + * @typedef {import('../types/detection.types.js').StatusSelectorConfig} StatusSelectorConfig + * @typedef {import('../types/detection.types.js').ChallengeConfig} ChallengeConfig + * @typedef {import('../types/detection.types.js').InterferenceDetector} InterferenceDetector + */ + +import { checkSelectors, checkWindowProperties, matchesSelectors, matchesTextPatterns } from '../utils/detection-utils.js'; + +/** + * @implements {InterferenceDetector} + */ +export class BotDetection { + /** + * @param {BotDetectionConfig} config + */ + constructor(config) { + this.config = config; + } + + /** + * @returns {TypeDetectionResult} + */ + detect() { + const results = Object.entries(this.config || {}) + .filter(([_, challengeConfig]) => challengeConfig.state === 'enabled') + .map(([challengeId, challengeConfig]) => { + const detected = checkSelectors(challengeConfig.selectors) || checkWindowProperties(challengeConfig.windowProperties || []); + if (!detected) { + return null; + } + + const challengeStatus = this._findStatus(challengeConfig.statusSelectors); + return { + detected: true, + vendor: challengeConfig.vendor, + challengeType: challengeId, + challengeStatus, + }; + }) + .filter((result) => result !== null); + + return { + detected: results.length > 0, + interferenceType: 'botDetection', + results, + timestamp: Date.now(), + }; + } + + /** + * @param {StatusSelectorConfig[]} [statusSelectors] + * @returns {string|null} + */ + _findStatus(statusSelectors) { + if (!statusSelectors || !Array.isArray(statusSelectors)) { + return null; + } + + return ( + statusSelectors.find((statusConfig) => { + const { status, selectors, textPatterns, textSources } = statusConfig; + const hasMatch = matchesSelectors(selectors) || matchesTextPatterns(document.body, textPatterns, textSources); + return hasMatch ? status : null; + })?.status || null + ); + } +} diff --git a/injected/src/services/web-interference-detection/detections/detection-base.js b/injected/src/services/web-interference-detection/detections/detection-base.js new file mode 100644 index 0000000000..1c90be787d --- /dev/null +++ b/injected/src/services/web-interference-detection/detections/detection-base.js @@ -0,0 +1,74 @@ +/** + * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult + * @typedef {import('../types/detection.types.js').InterferenceDetector} InterferenceDetector + */ + +/** + * PROTOTYPE: Base class for complex detections with continuous monitoring + * TODO: Add mutation observer, re-rooting, callback timers, debouncing + * @implements {InterferenceDetector} + */ +export class DetectionBase { + /** + * @param {object} config + * @param {((result: TypeDetectionResult) => void)|null} [onInterferenceChange] + */ + constructor(config, onInterferenceChange = null) { + this.config = config; + this.onInterferenceChange = onInterferenceChange; + this.isRunning = false; + this.root = null; + this.pollTimer = null; + + if (this.onInterferenceChange) { + this.start(); + } + } + + start() { + if (this.isRunning) { + return; + } + this.isRunning = true; + + this.root = this.findRoot(); + if (!this.root) { + setTimeout(() => this.start(), 500); + return; + } + + if (this.config.pollInterval) { + this.pollTimer = setInterval(() => this.checkForInterference(), this.config.pollInterval); + } + + this.checkForInterference(); + } + + stop() { + if (!this.isRunning) { + return; + } + this.isRunning = false; + + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } + + /** + * @returns {TypeDetectionResult} + */ + detect() { + throw new Error('detect() must be implemented by subclass'); + } + + /** + * @returns {Element|null} + */ + findRoot() { + return document.body; + } + + checkForInterference() {} +} diff --git a/injected/src/services/web-interference-detection/detections/fraud-detection.js b/injected/src/services/web-interference-detection/detections/fraud-detection.js new file mode 100644 index 0000000000..c822f21549 --- /dev/null +++ b/injected/src/services/web-interference-detection/detections/fraud-detection.js @@ -0,0 +1,50 @@ +/** + * @typedef {import('../types/detection.types.js').AntiFraudConfig} AntiFraudConfig + * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult + * @typedef {import('../types/detection.types.js').AntiFraudAlertConfig} AntiFraudAlertConfig + * @typedef {import('../types/detection.types.js').InterferenceDetector} InterferenceDetector + */ + +import { checkSelectorsWithVisibility, checkTextPatterns } from '../utils/detection-utils.js'; + +/** + * @implements {InterferenceDetector} + */ +export class FraudDetection { + /** + * @param {AntiFraudConfig} config + */ + constructor(config) { + this.config = config; + } + + /** + * @returns {TypeDetectionResult} + */ + detect() { + const results = Object.entries(this.config || {}) + .filter(([_, alertConfig]) => alertConfig.state === 'enabled') + .map(([alertId, alertConfig]) => { + const detected = + checkSelectorsWithVisibility(alertConfig.selectors) || + checkTextPatterns(alertConfig.textPatterns, alertConfig.textSources); + if (!detected) { + return null; + } + + return { + detected: true, + alertId, + type: alertConfig.type, + }; + }) + .filter((result) => result !== null); + + return { + detected: results.length > 0, + interferenceType: 'fraudDetection', + results, + timestamp: Date.now(), + }; + } +} diff --git a/injected/src/services/web-interference-detection/detections/youtube-ads-detection.js b/injected/src/services/web-interference-detection/detections/youtube-ads-detection.js new file mode 100644 index 0000000000..33c47b1d7a --- /dev/null +++ b/injected/src/services/web-interference-detection/detections/youtube-ads-detection.js @@ -0,0 +1,89 @@ +/** + * @typedef {import('../types/detection.types.js').YouTubeAdsConfig} YouTubeAdsConfig + * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult + * @typedef {import('../types/detection.types.js').InterferenceDetector} InterferenceDetector + */ + +import { DetectionBase } from './detection-base.js'; +import { isVisible, queryAllSelectors } from '../utils/detection-utils.js'; +import { createEmptyResult } from '../utils/result-factory.js'; + +/** + * PROTOTYPE: YouTube ad detection + * TODO: Add mutation-based detection, ad lifecycle tracking, sponsored content badges + * @implements {InterferenceDetector} + */ +export class YouTubeAdsDetection extends DetectionBase { + /** + * @param {YouTubeAdsConfig} config + * @param {((result: TypeDetectionResult) => void)|null} [onInterferenceChange] + */ + constructor(config, onInterferenceChange = null) { + super(config, onInterferenceChange); + this.adCurrentlyPlaying = false; + } + + /** + * @returns {TypeDetectionResult} + */ + detect() { + const root = this.findRoot(); + if (!root) { + return createEmptyResult('youtubeAds'); + } + + const hasAdClass = this.config.adClasses.some((/** @type {string} */ cls) => root.classList.contains(cls)); + const adElements = queryAllSelectors(this.config.selectors, root); + const hasVisibleAdElement = adElements.some((el) => isVisible(el)); + + const detected = hasAdClass || hasVisibleAdElement; + + return { + detected, + interferenceType: 'youtubeAds', + results: detected + ? [ + { + adCurrentlyPlaying: true, + adType: 'video-ad', + source: 'one-time-detection', + }, + ] + : [], + timestamp: Date.now(), + }; + } + + findRoot() { + return document.querySelector(this.config.rootSelector); + } + + checkForInterference() { + if (!this.root) { + return; + } + + const hadAd = this.adCurrentlyPlaying; + const hasAdClass = this.config.adClasses.some((/** @type {string} */ cls) => this.root && this.root.classList.contains(cls)); + this.adCurrentlyPlaying = hasAdClass; + + if (this.onInterferenceChange && hadAd !== this.adCurrentlyPlaying) { + this.onInterferenceChange( + this.adCurrentlyPlaying + ? { + detected: true, + interferenceType: 'youtubeAds', + results: [ + { + adCurrentlyPlaying: true, + adType: 'video-ad', + source: 'detector', + }, + ], + timestamp: Date.now(), + } + : createEmptyResult('youtubeAds'), + ); + } + } +} diff --git a/injected/src/services/web-interference-detection/detector-service.js b/injected/src/services/web-interference-detection/detector-service.js new file mode 100644 index 0000000000..5868c4c6d6 --- /dev/null +++ b/injected/src/services/web-interference-detection/detector-service.js @@ -0,0 +1,81 @@ +/** + * @typedef {import('./types/detection.types.js').InterferenceType} InterferenceType + * @typedef {import('./types/detection.types.js').InterferenceConfig} InterferenceConfig + * @typedef {import('./types/detection.types.js').TypeDetectionResult} TypeDetectionResult + * @typedef {import('./types/detection.types.js').DetectionResults} DetectionResults + * @typedef {import('./types/detection.types.js').DetectInterferenceParams} DetectInterferenceParams + * @typedef {import('./types/detection.types.js').InterferenceDetector} InterferenceDetector + * @typedef {import('./types/api.types.js').InterferenceDetectionRequest} InterferenceDetectionRequest + */ + +import { BotDetection } from './detections/bot-detection.js'; +import { FraudDetection } from './detections/fraud-detection.js'; +import { YouTubeAdsDetection } from './detections/youtube-ads-detection.js'; +import { createEmptyResult } from './utils/result-factory.js'; + +const detectionClassMap = { + botDetection: BotDetection, + fraudDetection: FraudDetection, + youtubeAds: YouTubeAdsDetection, +}; + +class WebInterferenceDetectionService { + /** + * @param {DetectInterferenceParams} params + */ + constructor(params) { + const { interferenceConfig, onDetectionChange } = params; + this.interferenceConfig = interferenceConfig; + this.onDetectionChange = onDetectionChange; + this.activeDetections = []; + } + + /** + * @param {InterferenceDetectionRequest} request + * @returns {DetectionResults} + */ + detect(request) { + const { types } = request; + const results = /** @type {DetectionResults} */ ({}); + types.forEach((type) => { + const DetectionClass = detectionClassMap[type]; + if (!DetectionClass) { + throw new Error(`Unsupported interference type: "${type}". Supported types: ${Object.keys(detectionClassMap).join(', ')}`); + } + + const config = this.interferenceConfig.settings?.[type]; + if (!config) { + results[type] = createEmptyResult(type); + return; + } + + const { observeDOMChanges } = config ?? {}; + + const callback = + observeDOMChanges && this.onDetectionChange + ? (/** @type {TypeDetectionResult} */ result) => this.onDetectionChange?.({ [type]: result }) + : null; + + const detection = /** @type {InterferenceDetector} */ (new DetectionClass(config, callback)); + results[type] = detection.detect(); + + if (callback && typeof detection.stop === 'function') { + this.activeDetections.push(detection); + } + }); + + return results; + } + + cleanup() { + this.activeDetections.forEach((detection) => detection.stop()); + } +} + +/** + * @param {DetectInterferenceParams} params + * @returns {WebInterferenceDetectionService} + */ +export function createWebInterferenceService(params) { + return new WebInterferenceDetectionService(params); +} diff --git a/injected/src/services/web-interference-detection/types/api.types.js b/injected/src/services/web-interference-detection/types/api.types.js new file mode 100644 index 0000000000..8ad5a4f180 --- /dev/null +++ b/injected/src/services/web-interference-detection/types/api.types.js @@ -0,0 +1,14 @@ +/** + * API types for native messaging interface + */ + +/** + * @typedef {import('./detection.types').InterferenceType} InterferenceType + */ + +/** + * @typedef {object} InterferenceDetectionRequest + * @property {InterferenceType[]} types - Interference types to detect + */ + +export {}; diff --git a/injected/src/services/web-interference-detection/types/detection.types.js b/injected/src/services/web-interference-detection/types/detection.types.js new file mode 100644 index 0000000000..6923db39be --- /dev/null +++ b/injected/src/services/web-interference-detection/types/detection.types.js @@ -0,0 +1,124 @@ +/** + * Core detection types for web interference detection service + */ + +/** + * @typedef {'botDetection' | 'youtubeAds' | 'fraudDetection'} InterferenceType + */ + +/** + * @typedef {'cloudflare' | 'hcaptcha'} VendorName + */ + +/** + * @typedef {'turnstile' | 'challengePage'} ChallengeType + */ + +/** + * @typedef {'cloudflareTurnstile' | 'cloudflareChallengePage' | 'hcaptcha'} ChallengeIdentifier + */ + +/** + * @typedef {'enabled' | 'disabled'} FeatureState + */ + +/** + * @typedef {object} StatusSelectorConfig + * @property {string} status + * @property {string[]} [selectors] + * @property {string[]} [textPatterns] + * @property {string[]} [textSources] + */ + +/** + * @typedef {object} ChallengeConfig + * @property {FeatureState} state + * @property {VendorName} vendor + * @property {string[]} [selectors] + * @property {string[]} [windowProperties] + * @property {StatusSelectorConfig[]} [statusSelectors] + * @property {boolean} [observeDOMChanges] + */ + +/** + * @typedef {Partial>} BotDetectionConfig + */ + +/** + * @typedef {object} AntiFraudAlertConfig + * @property {FeatureState} state + * @property {string} type + * @property {string[]} [selectors] + * @property {string[]} [textPatterns] + * @property {string[]} [textSources] + * @property {boolean} [observeDOMChanges] + */ + +/** + * @typedef {Record} AntiFraudConfig + */ + +/** + * @typedef {object} YouTubeAdsConfig + * @property {string} rootSelector + * @property {string[]} watchAttributes + * @property {string[]} selectors + * @property {string[]} adClasses + * @property {string[]} [textPatterns] + * @property {string[]} [textSources] + * @property {number} [pollInterval] + * @property {number} [rerootInterval] + * @property {boolean} [observeDOMChanges] + */ + +/** + * @typedef {object} InterferenceSettings + * @property {BotDetectionConfig} [botDetection] + * @property {AntiFraudConfig} [fraudDetection] + * @property {YouTubeAdsConfig} [youtubeAds] + */ + +/** + * @typedef {object} InterferenceConfig + * @property {InterferenceSettings} settings + */ + +/** + * @typedef {object} VendorDetectionResult + * @property {boolean} detected - Whether vendor was detected + * @property {VendorName} vendor - Vendor identifier + * @property {string} challengeType - Challenge identifier + * @property {string | null} challengeStatus - Challenge status + */ + +/** + * @typedef {object} TypeDetectionResult + * @property {boolean} detected + * @property {InterferenceType} interferenceType + * @property {Record[]} [results] + * @property {number} timestamp + */ + +/** + * @typedef {object} InterferenceDetector + * @property {() => TypeDetectionResult} detect + * @property {() => void} [stop] + */ + +/** + * @callback TypeDetectorFunction + * @param {InterferenceConfig} interferenceConfig + * @returns {TypeDetectionResult} + */ + +/** + * @typedef {object} DetectInterferenceParams + * @property {InterferenceConfig} interferenceConfig + * @property {((result: DetectionResults) => void)|null} [onDetectionChange] + */ + +/** + * @typedef {Partial>} DetectionResults + */ + +export {}; diff --git a/injected/src/services/web-interference-detection/utils/detection-utils.js b/injected/src/services/web-interference-detection/utils/detection-utils.js new file mode 100644 index 0000000000..7419596b7d --- /dev/null +++ b/injected/src/services/web-interference-detection/utils/detection-utils.js @@ -0,0 +1,117 @@ +/** + * @param {string[]} [selectors] + * @returns {boolean} + */ +export function checkSelectors(selectors) { + if (!selectors || !Array.isArray(selectors)) { + return false; + } + return selectors.some((selector) => document.querySelector(selector)); +} + +/** + * @param {string[]} [selectors] + * @returns {boolean} + */ +export function checkSelectorsWithVisibility(selectors) { + if (!selectors || !Array.isArray(selectors)) { + return false; + } + return selectors.some((selector) => { + const element = document.querySelector(selector); + return element && isVisible(element); + }); +} + +/** + * @param {string[]} [properties] + * @returns {boolean} + */ +export function checkWindowProperties(properties) { + if (!properties || !Array.isArray(properties)) { + return false; + } + return properties.some((prop) => typeof window?.[prop] !== 'undefined'); +} + +/** + * @param {Element} element + * @returns {boolean} + */ +export function isVisible(element) { + const computedStyle = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return ( + rect.width > 0.5 && + rect.height > 0.5 && + computedStyle.display !== 'none' && + computedStyle.visibility !== 'hidden' && + +computedStyle.opacity > 0.05 + ); +} + +/** + * @param {Element} element + * @param {string[]} [sources] + * @returns {string} + */ +export function getTextContent(element, sources) { + if (!sources || sources.length === 0) { + return element.textContent || ''; + } + return sources.map((source) => element[source] || '').join(' '); +} + +/** + * @param {string[]} [selectors] + * @returns {boolean} + */ +export function matchesSelectors(selectors) { + if (!selectors || !Array.isArray(selectors)) { + return false; + } + const elements = queryAllSelectors(selectors); + return elements.length > 0; +} + +/** + * @param {Element} element + * @param {string[]} [patterns] + * @param {string[]} [sources] + * @returns {boolean} + */ +export function matchesTextPatterns(element, patterns, sources) { + if (!patterns || !Array.isArray(patterns)) { + return false; + } + const text = getTextContent(element, sources); + return patterns.some((pattern) => { + const regex = new RegExp(pattern, 'i'); + return regex.test(text); + }); +} + +/** + * @param {string[]} [patterns] + * @param {string[]} [sources] + * @returns {boolean} + */ +export function checkTextPatterns(patterns, sources) { + if (!patterns || !Array.isArray(patterns)) { + return false; + } + return matchesTextPatterns(document.body, patterns, sources); +} + +/** + * @param {string[]} selectors + * @param {Element|Document} [root] + * @returns {Element[]} + */ +export function queryAllSelectors(selectors, root = document) { + if (!selectors || !Array.isArray(selectors) || selectors.length === 0) { + return []; + } + const elements = root.querySelectorAll(selectors.join(',')); + return Array.from(elements); +} diff --git a/injected/src/services/web-interference-detection/utils/result-factory.js b/injected/src/services/web-interference-detection/utils/result-factory.js new file mode 100644 index 0000000000..60a02205cd --- /dev/null +++ b/injected/src/services/web-interference-detection/utils/result-factory.js @@ -0,0 +1,17 @@ +/** + * @typedef {import('../types/detection.types.js').InterferenceType} InterferenceType + * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult + */ + +/** + * @param {InterferenceType} type + * @returns {TypeDetectionResult} + */ +export function createEmptyResult(type) { + return { + detected: false, + interferenceType: type, + results: [], + timestamp: Date.now(), + }; +} From 3abdc0564fb015625f040059d07edff6fca4e557 Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 10:26:13 -0700 Subject: [PATCH 02/13] detector prototype v2 --- injected/src/detectors/README.md | 71 ++++++++ injected/src/detectors/default-config.js | 58 ++++++ .../src/detectors/detections/bot-detection.js | 58 ++++++ .../detections/detection-base.js | 19 +- .../detectors/detections/fraud-detection.js | 36 ++++ .../detections/youtube-ads-detection.js | 52 ++++++ injected/src/detectors/detector-service.js | 92 ++++++++++ .../utils/detection-utils.js | 0 injected/src/features/broker-protection.js | 20 --- .../features/web-interference-detection.js | 73 ++++---- .../web-interference-detection/README.md | 165 ------------------ .../default-config.js | 74 -------- .../detections/bot-detection.js | 69 -------- .../detections/fraud-detection.js | 50 ------ .../detections/youtube-ads-detection.js | 89 ---------- .../detector-service.js | 81 --------- .../types/api.types.js | 14 -- .../types/detection.types.js | 124 ------------- .../utils/result-factory.js | 17 -- 19 files changed, 413 insertions(+), 749 deletions(-) create mode 100644 injected/src/detectors/README.md create mode 100644 injected/src/detectors/default-config.js create mode 100644 injected/src/detectors/detections/bot-detection.js rename injected/src/{services/web-interference-detection => detectors}/detections/detection-base.js (76%) create mode 100644 injected/src/detectors/detections/fraud-detection.js create mode 100644 injected/src/detectors/detections/youtube-ads-detection.js create mode 100644 injected/src/detectors/detector-service.js rename injected/src/{services/web-interference-detection => detectors}/utils/detection-utils.js (100%) delete mode 100644 injected/src/services/web-interference-detection/README.md delete mode 100644 injected/src/services/web-interference-detection/default-config.js delete mode 100644 injected/src/services/web-interference-detection/detections/bot-detection.js delete mode 100644 injected/src/services/web-interference-detection/detections/fraud-detection.js delete mode 100644 injected/src/services/web-interference-detection/detections/youtube-ads-detection.js delete mode 100644 injected/src/services/web-interference-detection/detector-service.js delete mode 100644 injected/src/services/web-interference-detection/types/api.types.js delete mode 100644 injected/src/services/web-interference-detection/types/detection.types.js delete mode 100644 injected/src/services/web-interference-detection/utils/result-factory.js diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md new file mode 100644 index 0000000000..7360b474bc --- /dev/null +++ b/injected/src/detectors/README.md @@ -0,0 +1,71 @@ +# Detector Registry (Prototype) + +This directory contains a lightweight registry that runs inside content-scope-scripts. Detectors register with the shared service and any feature can query their latest results (breakage reporting, native PIR, debug tooling, etc.). + +The initial focus is synchronous, on-demand collection. Continuous monitoring (mutation observers, polling, batching) can be layered on later without changing the public API. + +## API Snapshot + +```mermaid +sequenceDiagram + participant Feature as Breakage Reporting + participant Service as detectorService + participant Detector as YouTubeDetector + + Detector->>Service: registerDetector('youtubeAds', { getData }) + Feature->>Service: getDetectorData('youtubeAds') + Service->>Detector: getData() + Detector-->>Service: snapshot + Service-->>Feature: snapshot +``` + +### Core helpers + +- `registerDetector(detectorId, { getData, refresh?, teardown? })` +- `unregisterDetector(detectorId)` +- `resetDetectors(reason?)` +- `getDetectorData(detectorId, { maxAgeMs }?)` +- `getDetectorBatch(detectorIds, options?)` + +Detectors return arbitrary JSON payloads. Include timestamps if consumers rely on freshness. + +## Directory Layout + +``` +detectors/ +├── detector-service.js # registry + caching helpers +├── default-config.js # sample configuration blobs +├── detections/ +│ ├── bot-detection.js # helpers for CAPTCHA/bot detection +│ ├── fraud-detection.js # helpers for anti-fraud banners +│ └── youtube-ads-detection.js # helper for YouTube ad snapshots +├── detections/detection-base.js # optional base for observer-style detectors +└── utils/ + └── detection-utils.js # DOM helpers (selectors, text matching, visibility) +``` + +## Example Usage + +```javascript +import { registerDetector, getDetectorData } from '../detectors/detector-service.js'; +import { createBotDetector } from '../detectors/detections/bot-detection.js'; +import { DEFAULT_DETECTOR_SETTINGS } from '../detectors/default-config.js'; + +// During feature init +registerDetector('botDetection', createBotDetector(DEFAULT_DETECTOR_SETTINGS.botDetection)); + +// Later, when preparing a breakage report +const snapshot = await getDetectorData('botDetection', { maxAgeMs: 1_000 }); +if (snapshot?.detected) { + payload.detectors.bot = snapshot; +} +``` + +## Extending + +1. Add a helper under `detections/` that exposes a `createXDetector(config)` returning `{ getData, refresh?, teardown? }`. +2. Register it during feature bootstrap or via a shared initializer. +3. (Optional) Add defaults to `default-config.js` or wire it to remote config. + +Future enhancements—shared observers, background aggregation, streaming updates—can build on this registry without breaking the public API. + diff --git a/injected/src/detectors/default-config.js b/injected/src/detectors/default-config.js new file mode 100644 index 0000000000..8bae5a8a9c --- /dev/null +++ b/injected/src/detectors/default-config.js @@ -0,0 +1,58 @@ +export const DEFAULT_DETECTOR_SETTINGS = Object.freeze({ + botDetection: { + cloudflareTurnstile: { + state: 'enabled', + vendor: 'cloudflare', + selectors: ['.cf-turnstile', 'script[src*="challenges.cloudflare.com"]'], + windowProperties: ['turnstile'], + statusSelectors: [ + { + status: 'solved', + selectors: ['[data-state="success"]'], + }, + { + status: 'failed', + selectors: ['[data-state="error"]'], + }, + ], + }, + cloudflareChallengePage: { + state: 'enabled', + vendor: 'cloudflare', + selectors: ['#challenge-form', '.cf-browser-verification', '#cf-wrapper', 'script[src*="challenges.cloudflare.com"]'], + windowProperties: ['_cf_chl_opt', '__CF$cv$params', 'cfjsd'], + }, + hcaptcha: { + state: 'enabled', + vendor: 'hcaptcha', + selectors: [ + '.h-captcha', + '[data-hcaptcha-widget-id]', + 'script[src*="hcaptcha.com"]', + 'script[src*="assets.hcaptcha.com"]', + ], + windowProperties: ['hcaptcha'], + }, + }, + fraudDetection: { + phishingWarning: { + state: 'enabled', + type: 'phishing', + selectors: ['.warning-banner', '#security-alert'], + textPatterns: ['suspicious.*activity', 'unusual.*login', 'verify.*account'], + textSources: ['innerText'], + }, + accountSuspension: { + state: 'enabled', + type: 'suspension', + selectors: ['.account-suspended', '#suspension-notice'], + textPatterns: ['account.*suspended', 'access.*restricted'], + textSources: ['innerText'], + }, + }, + youtubeAds: { + rootSelector: '#movie_player', + selectors: ['.ytp-ad-text', '.ytp-ad-skip-button', '.ytp-ad-preview-text'], + adClasses: ['ad-showing', 'ad-interrupting'], + }, +}); diff --git a/injected/src/detectors/detections/bot-detection.js b/injected/src/detectors/detections/bot-detection.js new file mode 100644 index 0000000000..ca57510df8 --- /dev/null +++ b/injected/src/detectors/detections/bot-detection.js @@ -0,0 +1,58 @@ +import { checkSelectors, checkWindowProperties, matchesSelectors, matchesTextPatterns } from '../utils/detection-utils.js'; + +/** + * Create a detector registration for CAPTCHA/bot detection. + * @param {Record} config + */ +export function createBotDetector(config = {}) { + return { + async getData() { + return runBotDetection(config); + }, + }; +} + +/** + * Run detection immediately and return structured results. + * @param {Record} config + */ +export function runBotDetection(config = {}) { + const results = Object.entries(config) + .filter(([_, challengeConfig]) => challengeConfig?.state === 'enabled') + .map(([challengeId, challengeConfig]) => { + const detected = checkSelectors(challengeConfig.selectors) || checkWindowProperties(challengeConfig.windowProperties || []); + if (!detected) { + return null; + } + + const challengeStatus = findStatus(challengeConfig.statusSelectors); + return { + detected: true, + vendor: challengeConfig.vendor, + challengeType: challengeId, + challengeStatus, + }; + }) + .filter(Boolean); + + return { + detected: results.length > 0, + type: 'botDetection', + results, + timestamp: Date.now(), + }; +} + +function findStatus(statusSelectors) { + if (!Array.isArray(statusSelectors)) { + return null; + } + + const match = statusSelectors.find((statusConfig) => { + const { selectors, textPatterns, textSources } = statusConfig; + return matchesSelectors(selectors) || matchesTextPatterns(document.body, textPatterns, textSources); + }); + + return match?.status ?? null; +} + diff --git a/injected/src/services/web-interference-detection/detections/detection-base.js b/injected/src/detectors/detections/detection-base.js similarity index 76% rename from injected/src/services/web-interference-detection/detections/detection-base.js rename to injected/src/detectors/detections/detection-base.js index 1c90be787d..ee1f28c16f 100644 --- a/injected/src/services/web-interference-detection/detections/detection-base.js +++ b/injected/src/detectors/detections/detection-base.js @@ -1,17 +1,11 @@ -/** - * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult - * @typedef {import('../types/detection.types.js').InterferenceDetector} InterferenceDetector - */ - /** * PROTOTYPE: Base class for complex detections with continuous monitoring * TODO: Add mutation observer, re-rooting, callback timers, debouncing - * @implements {InterferenceDetector} */ export class DetectionBase { /** * @param {object} config - * @param {((result: TypeDetectionResult) => void)|null} [onInterferenceChange] + * @param {(result: any) => void=} onInterferenceChange */ constructor(config, onInterferenceChange = null) { this.config = config; @@ -19,6 +13,7 @@ export class DetectionBase { this.isRunning = false; this.root = null; this.pollTimer = null; + this.retryTimer = null; if (this.onInterferenceChange) { this.start(); @@ -33,7 +28,7 @@ export class DetectionBase { this.root = this.findRoot(); if (!this.root) { - setTimeout(() => this.start(), 500); + this.retryTimer = setTimeout(() => this.start(), 500); return; } @@ -54,11 +49,13 @@ export class DetectionBase { clearInterval(this.pollTimer); this.pollTimer = null; } + + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } } - /** - * @returns {TypeDetectionResult} - */ detect() { throw new Error('detect() must be implemented by subclass'); } diff --git a/injected/src/detectors/detections/fraud-detection.js b/injected/src/detectors/detections/fraud-detection.js new file mode 100644 index 0000000000..0754bf0a38 --- /dev/null +++ b/injected/src/detectors/detections/fraud-detection.js @@ -0,0 +1,36 @@ +import { checkSelectorsWithVisibility, checkTextPatterns } from '../utils/detection-utils.js'; + +export function createFraudDetector(config = {}) { + return { + async getData() { + return runFraudDetection(config); + }, + }; +} + +export function runFraudDetection(config = {}) { + const results = Object.entries(config) + .filter(([_, alertConfig]) => alertConfig?.state === 'enabled') + .map(([alertId, alertConfig]) => { + const detected = + checkSelectorsWithVisibility(alertConfig.selectors) || + checkTextPatterns(alertConfig.textPatterns, alertConfig.textSources); + if (!detected) { + return null; + } + + return { + detected: true, + alertId, + category: alertConfig.type, + }; + }) + .filter(Boolean); + + return { + detected: results.length > 0, + type: 'fraudDetection', + results, + timestamp: Date.now(), + }; +} diff --git a/injected/src/detectors/detections/youtube-ads-detection.js b/injected/src/detectors/detections/youtube-ads-detection.js new file mode 100644 index 0000000000..e51b687fdb --- /dev/null +++ b/injected/src/detectors/detections/youtube-ads-detection.js @@ -0,0 +1,52 @@ +import { isVisible, queryAllSelectors } from '../utils/detection-utils.js'; +const DEFAULT_CONFIG = { + rootSelector: '#movie_player', + adClasses: ['ad-showing', 'ad-interrupting'], + selectors: ['.ytp-ad-text', '.ytp-ad-skip-button', '.ytp-ad-preview-text'], +}; + +export function createYouTubeAdsDetector(config = {}) { + const mergedConfig = { ...DEFAULT_CONFIG, ...config }; + return { + async getData() { + return runYouTubeAdsDetection(mergedConfig); + }, + }; +} + +export function runYouTubeAdsDetection(config = DEFAULT_CONFIG) { + const root = document.querySelector(config.rootSelector); + if (!root) { + return emptyResult(); + } + + const hasAdClass = config.adClasses.some((cls) => root.classList.contains(cls)); + const adElements = queryAllSelectors(config.selectors, root); + const hasVisibleAdElement = adElements.some((el) => isVisible(el)); + + const detected = hasAdClass || hasVisibleAdElement; + + return detected + ? { + detected: true, + type: 'youtubeAds', + results: [ + { + adCurrentlyPlaying: true, + adType: 'video-ad', + source: 'snapshot', + }, + ], + timestamp: Date.now(), + } + : emptyResult(); +} + +function emptyResult() { + return { + detected: false, + type: 'youtubeAds', + results: [], + timestamp: Date.now(), + }; +} diff --git a/injected/src/detectors/detector-service.js b/injected/src/detectors/detector-service.js new file mode 100644 index 0000000000..522762a413 --- /dev/null +++ b/injected/src/detectors/detector-service.js @@ -0,0 +1,92 @@ +/** + * @typedef {object} DetectorRegistration + * @property {() => any | Promise} getData + * @property {(() => any | Promise)=} refresh + * @property {(() => void)=} teardown + * + * @typedef {object} CachedSnapshot + * @property {any} data + * @property {number} ts + */ + +const registrations = new Map(); +const cache = new Map(); + +/** + * Register a detector with the shared service. + * Subsequent calls replace the previous registration for the same id. + * @param {string} detectorId + * @param {DetectorRegistration} registration + */ +export function registerDetector(detectorId, registration) { + registrations.set(detectorId, registration); +} + +/** + * Remove a detector registration and drop any cached data. + * @param {string} detectorId + */ +export function unregisterDetector(detectorId) { + const registration = registrations.get(detectorId); + registration?.teardown?.(); + registrations.delete(detectorId); + cache.delete(detectorId); +} + +/** + * Reset all detector caches and invoke teardowns. + * @param {string} [reason] + */ +export function resetDetectors(reason = 'manual') { + for (const registration of registrations.values()) { + registration.teardown?.(reason); + } + cache.clear(); +} + +/** + * Fetch detector data. Uses cached value when available unless maxAgeMs is exceeded. + * @param {string} detectorId + * @param {{ maxAgeMs?: number }} [options] + * @returns {Promise} + */ +export async function getDetectorData(detectorId, options = {}) { + const { maxAgeMs } = options; + const cached = /** @type {CachedSnapshot | undefined} */ (cache.get(detectorId)); + if (cached) { + const age = Date.now() - cached.ts; + if (!maxAgeMs || age <= maxAgeMs) { + return cached.data; + } + } + + const registration = registrations.get(detectorId); + if (!registration) { + return null; + } + + const runner = registration.refresh ?? registration.getData; + try { + const data = await runner(); + cache.set(detectorId, { data, ts: Date.now() }); + return data; + } catch (error) { + console.error(`[detectorService] Failed to fetch data for ${detectorId}`, error); + return null; + } +} + +/** + * Convenience helper for fetching multiple detectors at once. + * @param {string[]} detectorIds + * @param {{ maxAgeMs?: number }} [options] + * @returns {Promise>} + */ +export async function getDetectorBatch(detectorIds, options = {}) { + const results = {}; + for (const detectorId of detectorIds) { + results[detectorId] = await getDetectorData(detectorId, options); + } + return results; +} + diff --git a/injected/src/services/web-interference-detection/utils/detection-utils.js b/injected/src/detectors/utils/detection-utils.js similarity index 100% rename from injected/src/services/web-interference-detection/utils/detection-utils.js rename to injected/src/detectors/utils/detection-utils.js diff --git a/injected/src/features/broker-protection.js b/injected/src/features/broker-protection.js index c30ae0f741..05294dc88e 100644 --- a/injected/src/features/broker-protection.js +++ b/injected/src/features/broker-protection.js @@ -1,13 +1,7 @@ -/** - * @typedef {import('../services/web-interference-detection/types/api.types.js').InterferenceDetectionRequest} InterferenceDetectionRequest - */ - import ContentFeature from '../content-feature.js'; import { execute } from './broker-protection/execute.js'; import { retry } from '../timer-utils.js'; import { ErrorResponse } from './broker-protection/types.js'; -import { createWebInterferenceService } from '../services/web-interference-detection/detector-service.js'; -import { DEFAULT_INTERFERENCE_CONFIG } from '../services/web-interference-detection/default-config.js'; export class ActionExecutorBase extends ContentFeature { /** @@ -93,24 +87,10 @@ export class ActionExecutorBase extends ContentFeature { */ export default class BrokerProtection extends ActionExecutorBase { init() { - const interferenceConfig = this.getFeatureAttr('interferenceTypes', DEFAULT_INTERFERENCE_CONFIG); - const service = createWebInterferenceService({ interferenceConfig }); - this.messaging.subscribe('onActionReceived', async (/** @type {any} */ params) => { const { action, data } = params.state; return await this.processActionAndNotify(action, data); }); - - this.messaging.subscribe('detectInterference', (/** @type {InterferenceDetectionRequest} */ request) => { - try { - const detectionResults = service.detect(request); - console.log('[BrokerProtection] Detection results:', detectionResults); - return this.messaging.notify('interferenceDetected', detectionResults); - } catch (error) { - console.error('[BrokerProtection] Error detecting interference:', error); - return this.messaging.notify('interferenceDetectionError', { error: error.toString() }); - } - }); } /** diff --git a/injected/src/features/web-interference-detection.js b/injected/src/features/web-interference-detection.js index 4a61275bce..5661d34a53 100644 --- a/injected/src/features/web-interference-detection.js +++ b/injected/src/features/web-interference-detection.js @@ -1,10 +1,9 @@ -/** - * @typedef {import('../services/web-interference-detection/types/api.types.js').InterferenceDetectionRequest} InterferenceDetectionRequest - */ - import ContentFeature from '../content-feature.js'; -import { createWebInterferenceService } from '../services/web-interference-detection/detector-service.js'; -import { DEFAULT_INTERFERENCE_CONFIG } from '../services/web-interference-detection/default-config.js'; +import { registerDetector, getDetectorBatch, resetDetectors } from '../detectors/detector-service.js'; +import { DEFAULT_DETECTOR_SETTINGS } from '../detectors/default-config.js'; +import { createBotDetector } from '../detectors/detections/bot-detection.js'; +import { createFraudDetector } from '../detectors/detections/fraud-detection.js'; +import { createYouTubeAdsDetector } from '../detectors/detections/youtube-ads-detection.js'; export default class WebInterferenceDetection extends ContentFeature { init() { @@ -12,42 +11,46 @@ export default class WebInterferenceDetection extends ContentFeature { if (!featureEnabled) { return; } - const interferenceConfig = this.getFeatureAttr('interferenceTypes', DEFAULT_INTERFERENCE_CONFIG); - const service = createWebInterferenceService({ - interferenceConfig, - onDetectionChange: (result) => { - this.messaging.notify('interferenceChanged', result); - }, - }); - /** - * Example: One-time detection - * Native -> CSS: Call detectInterference - * CSS -> Native: Return interferenceDetected with immediate results - */ - this.messaging.subscribe('detectInterference', (/** @type {InterferenceDetectionRequest} */ request) => { + const detectorSettings = { + ...DEFAULT_DETECTOR_SETTINGS, + ...this.getFeatureAttr('interferenceTypes', {}), + }; + + this._registerDefaults(detectorSettings); + + this.messaging.subscribe('detectInterference', async (params = {}) => { try { - const detectionResults = service.detect(request); - return this.messaging.notify('interferenceDetected', detectionResults); + const detectorIds = normalizeTypes(params.types); + const results = await getDetectorBatch(detectorIds); + return this.messaging.notify('interferenceDetected', { results }); } catch (error) { console.error('[WebInterferenceDetection] Detection failed:', error); return this.messaging.notify('interferenceDetectionError', { error: error.toString() }); } }); + } - /** - * Example: Continuous monitoring - * Native -> CSS: Call startInterferenceMonitoring - * CSS -> Native: Return monitoringStarted with initial results - * CSS -> Native: Send interferenceChanged whenever detection changes (for types with observeDOMChanges: true) - */ - this.messaging.subscribe('startInterferenceMonitoring', (/** @type {InterferenceDetectionRequest} */ request) => { - try { - service.detect(request); - } catch (error) { - console.error('[WebInterferenceDetection] Monitoring failed:', error); - return this.messaging.notify('interferenceDetectionError', { error: error.toString() }); - } - }); + destroy() { + resetDetectors('feature-destroyed'); + } + + _registerDefaults(settings) { + if (settings.botDetection) { + registerDetector('botDetection', createBotDetector(settings.botDetection)); + } + if (settings.fraudDetection) { + registerDetector('fraudDetection', createFraudDetector(settings.fraudDetection)); + } + if (settings.youtubeAds) { + registerDetector('youtubeAds', createYouTubeAdsDetector(settings.youtubeAds)); + } + } +} + +function normalizeTypes(types) { + if (!Array.isArray(types) || types.length === 0) { + return ['botDetection', 'fraudDetection', 'youtubeAds']; } + return types.filter((type) => typeof type === 'string'); } diff --git a/injected/src/services/web-interference-detection/README.md b/injected/src/services/web-interference-detection/README.md deleted file mode 100644 index 11a31a5593..0000000000 --- a/injected/src/services/web-interference-detection/README.md +++ /dev/null @@ -1,165 +0,0 @@ -# Web Interference Detection Service - -Detects bot challenges, anti-fraud warnings, and video ads on web pages. Supports on-demand detection and continuous DOM monitoring based on configuration. - -## Architecture - -```mermaid -graph TB - subgraph C-S-S - Feature[WebInterferenceDetection
ContentFeature] - Service[WebInterferenceDetectionService] - - subgraph Detectors["Detection Classes"] - Bot[BotDetection] - Fraud[FraudDetection] - YouTube[YouTubeAdsDetection] - end - - Base[DetectionBase] - Utils[detection-utils] - end - - NativeApps <-->|"messaging
(detectInterference,
startInterferenceMonitoring)"| Feature - Feature --> Service - Service --> Bot - Service --> Fraud - Service --> YouTube - YouTube --> Base - Base -.MutationObserver, polling.-> Base - Bot --> Utils - Fraud --> Utils - YouTube --> Utils -``` - -### Folder Structure - -``` -web-interference-detection/ -├── detector-service.js # Main service orchestrator -├── default-config.js # Default configuration -├── detections/ -│ ├── bot-detection.js # CAPTCHA detection -│ ├── fraud-detection.js # Anti-fraud warnings -│ ├── youtube-ads-detection.js # Video ad detection -│ └── detection-base.js # Base class with observers -├── utils/ -│ ├── detection-utils.js # Shared utilities -│ └── result-factory.js # Result creation -└── types/ - ├── detection.types.js # Type definitions - └── api.types.js # API types -``` - -## Usage - -### On-Demand Detection - -```javascript -import { createWebInterferenceService } from './detector-service.js'; - -const service = createWebInterferenceService({ - interferenceConfig, - onDetectionChange: null, -}); - -const results = service.detect({ types: ['botDetection'] }); -``` - -**Full Response Example:** - -```javascript -{ - botDetection: { - detected: true, - interferenceType: 'botDetection', - results: [ - { - detected: true, - vendor: 'cloudflare', - challengeType: 'cloudflareTurnstile', - challengeStatus: 'visible' - } - ], - timestamp: 1699283942123 - } -} -``` - -### Continuous Monitoring - -```javascript -const service = createWebInterferenceService({ - interferenceConfig, - onDetectionChange: (result) => { - // Called when detection state changes (e.g., ad starts/stops) - console.log('Interference changed:', result); - }, -}); - -service.detect({ types: ['youtubeAds'] }); -// Service will monitor DOM changes and invoke callback - -service.cleanup(); // Stop observers and cleanup -``` - -## Configuration Structure - -```javascript -{ - settings: { - botDetection: { - cloudflareTurnstile: { - state: 'enabled', - vendor: 'cloudflare', - selectors: ['.cf-turnstile', 'script[src*="challenges.cloudflare.com"]'], - windowProperties: ['turnstile'], - statusSelectors: [ - { - status: 'solved', - selectors: ['[data-state="success"]'] - }, - { - status: 'failed', - selectors: ['[data-state="error"]'] - } - ], - observeDOMChanges: false - } - }, - youtubeAds: { - rootSelector: '#movie_player', - watchAttributes: ['class', 'style', 'aria-label'], - selectors: ['.ytp-ad-text', '.ytp-ad-skip-button', '.ytp-ad-preview-text'], - adClasses: ['ad-showing', 'ad-interrupting'], - textPatterns: ['skip ad', 'sponsored'], - textSources: ['innerText', 'ariaLabel'], - pollInterval: 2000, - rerootInterval: 1000, - observeDOMChanges: true - }, - } -} -``` - -## Key Concepts - -**Interference Types**: - -- `botDetection` for bor detection mechanisms (captchas, cloudflare, etc) -- `fraudDetection` for anti-fraud warnings -- `youtubeAds` for youtube video ads - -**Config-Driven Behavior**: Each interference type has independent configuration. Set `observeDOMChanges: true` to enable continuous monitoring for that specific interference type. - -**Service Lifecycle**: The service is created once during feature initialization and reused throughout the page lifecycle. Call `detect(request)` with specific interference types whenever detection is needed. Call `cleanup()` when the page is unloaded to stop all active observers and polling. - -**Messaging**: Use `detectInterference` for on-demand checks. Use `startInterferenceMonitoring` for continuous observation with callbacks. - -## Caveats - -⚠️ **MutationObserver/Polling Reuse**: Current implementation creates new observers for each `detect()` call if `observeDOMChanges: true`. Multiple detections may create redundant observers and affect performance. Future iterations should consider one or more of: - -- Observer pooling/reuse across detections -- Debouncing detection calls -- Centralized DOM observation with multiplexed callbacks diff --git a/injected/src/services/web-interference-detection/default-config.js b/injected/src/services/web-interference-detection/default-config.js deleted file mode 100644 index 1e307dab74..0000000000 --- a/injected/src/services/web-interference-detection/default-config.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @typedef {import('./types/detection.types.js').InterferenceConfig} InterferenceConfig - */ - -/** - * @type {InterferenceConfig} - */ -export const DEFAULT_INTERFERENCE_CONFIG = Object.freeze( - /** @type {InterferenceConfig} */ ({ - settings: { - botDetection: { - cloudflareTurnstile: { - state: 'enabled', - vendor: 'cloudflare', - selectors: ['.cf-turnstile', 'script[src*="challenges.cloudflare.com"]'], - windowProperties: ['turnstile'], - statusSelectors: [ - { - status: 'solved', - selectors: ['[data-state="success"]'], - }, - { - status: 'failed', - selectors: ['[data-state="error"]'], - }, - ], - }, - cloudflareChallengePage: { - state: 'enabled', - vendor: 'cloudflare', - selectors: ['#challenge-form', '.cf-browser-verification', '#cf-wrapper', 'script[src*="challenges.cloudflare.com"]'], - windowProperties: ['_cf_chl_opt', '__CF$cv$params', 'cfjsd'], - }, - hcaptcha: { - state: 'enabled', - vendor: 'hcaptcha', - selectors: [ - '.h-captcha', - '[data-hcaptcha-widget-id]', - 'script[src*="hcaptcha.com"]', - 'script[src*="assets.hcaptcha.com"]', - ], - windowProperties: ['hcaptcha'], - }, - }, - fraudDetection: { - phishingWarning: { - state: 'enabled', - type: 'phishing', - selectors: ['.warning-banner', '#security-alert'], - textPatterns: ['suspicious.*activity', 'unusual.*login', 'verify.*account'], - textSources: ['innerText'], - }, - accountSuspension: { - state: 'enabled', - type: 'suspension', - selectors: ['.account-suspended', '#suspension-notice'], - textPatterns: ['account.*suspended', 'access.*restricted'], - textSources: ['innerText'], - }, - }, - youtubeAds: { - rootSelector: '#movie_player', - watchAttributes: ['class', 'style', 'aria-label'], - selectors: ['.ytp-ad-text', '.ytp-ad-skip-button', '.ytp-ad-preview-text'], - adClasses: ['ad-showing', 'ad-interrupting'], - textPatterns: ['skip ad', 'sponsored'], - textSources: ['innerText', 'ariaLabel'], - pollInterval: 2000, - rerootInterval: 1000, - }, - }, - }), -); diff --git a/injected/src/services/web-interference-detection/detections/bot-detection.js b/injected/src/services/web-interference-detection/detections/bot-detection.js deleted file mode 100644 index 1b1e754926..0000000000 --- a/injected/src/services/web-interference-detection/detections/bot-detection.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * @typedef {import('../types/detection.types.js').BotDetectionConfig} BotDetectionConfig - * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult - * @typedef {import('../types/detection.types.js').StatusSelectorConfig} StatusSelectorConfig - * @typedef {import('../types/detection.types.js').ChallengeConfig} ChallengeConfig - * @typedef {import('../types/detection.types.js').InterferenceDetector} InterferenceDetector - */ - -import { checkSelectors, checkWindowProperties, matchesSelectors, matchesTextPatterns } from '../utils/detection-utils.js'; - -/** - * @implements {InterferenceDetector} - */ -export class BotDetection { - /** - * @param {BotDetectionConfig} config - */ - constructor(config) { - this.config = config; - } - - /** - * @returns {TypeDetectionResult} - */ - detect() { - const results = Object.entries(this.config || {}) - .filter(([_, challengeConfig]) => challengeConfig.state === 'enabled') - .map(([challengeId, challengeConfig]) => { - const detected = checkSelectors(challengeConfig.selectors) || checkWindowProperties(challengeConfig.windowProperties || []); - if (!detected) { - return null; - } - - const challengeStatus = this._findStatus(challengeConfig.statusSelectors); - return { - detected: true, - vendor: challengeConfig.vendor, - challengeType: challengeId, - challengeStatus, - }; - }) - .filter((result) => result !== null); - - return { - detected: results.length > 0, - interferenceType: 'botDetection', - results, - timestamp: Date.now(), - }; - } - - /** - * @param {StatusSelectorConfig[]} [statusSelectors] - * @returns {string|null} - */ - _findStatus(statusSelectors) { - if (!statusSelectors || !Array.isArray(statusSelectors)) { - return null; - } - - return ( - statusSelectors.find((statusConfig) => { - const { status, selectors, textPatterns, textSources } = statusConfig; - const hasMatch = matchesSelectors(selectors) || matchesTextPatterns(document.body, textPatterns, textSources); - return hasMatch ? status : null; - })?.status || null - ); - } -} diff --git a/injected/src/services/web-interference-detection/detections/fraud-detection.js b/injected/src/services/web-interference-detection/detections/fraud-detection.js deleted file mode 100644 index c822f21549..0000000000 --- a/injected/src/services/web-interference-detection/detections/fraud-detection.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @typedef {import('../types/detection.types.js').AntiFraudConfig} AntiFraudConfig - * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult - * @typedef {import('../types/detection.types.js').AntiFraudAlertConfig} AntiFraudAlertConfig - * @typedef {import('../types/detection.types.js').InterferenceDetector} InterferenceDetector - */ - -import { checkSelectorsWithVisibility, checkTextPatterns } from '../utils/detection-utils.js'; - -/** - * @implements {InterferenceDetector} - */ -export class FraudDetection { - /** - * @param {AntiFraudConfig} config - */ - constructor(config) { - this.config = config; - } - - /** - * @returns {TypeDetectionResult} - */ - detect() { - const results = Object.entries(this.config || {}) - .filter(([_, alertConfig]) => alertConfig.state === 'enabled') - .map(([alertId, alertConfig]) => { - const detected = - checkSelectorsWithVisibility(alertConfig.selectors) || - checkTextPatterns(alertConfig.textPatterns, alertConfig.textSources); - if (!detected) { - return null; - } - - return { - detected: true, - alertId, - type: alertConfig.type, - }; - }) - .filter((result) => result !== null); - - return { - detected: results.length > 0, - interferenceType: 'fraudDetection', - results, - timestamp: Date.now(), - }; - } -} diff --git a/injected/src/services/web-interference-detection/detections/youtube-ads-detection.js b/injected/src/services/web-interference-detection/detections/youtube-ads-detection.js deleted file mode 100644 index 33c47b1d7a..0000000000 --- a/injected/src/services/web-interference-detection/detections/youtube-ads-detection.js +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @typedef {import('../types/detection.types.js').YouTubeAdsConfig} YouTubeAdsConfig - * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult - * @typedef {import('../types/detection.types.js').InterferenceDetector} InterferenceDetector - */ - -import { DetectionBase } from './detection-base.js'; -import { isVisible, queryAllSelectors } from '../utils/detection-utils.js'; -import { createEmptyResult } from '../utils/result-factory.js'; - -/** - * PROTOTYPE: YouTube ad detection - * TODO: Add mutation-based detection, ad lifecycle tracking, sponsored content badges - * @implements {InterferenceDetector} - */ -export class YouTubeAdsDetection extends DetectionBase { - /** - * @param {YouTubeAdsConfig} config - * @param {((result: TypeDetectionResult) => void)|null} [onInterferenceChange] - */ - constructor(config, onInterferenceChange = null) { - super(config, onInterferenceChange); - this.adCurrentlyPlaying = false; - } - - /** - * @returns {TypeDetectionResult} - */ - detect() { - const root = this.findRoot(); - if (!root) { - return createEmptyResult('youtubeAds'); - } - - const hasAdClass = this.config.adClasses.some((/** @type {string} */ cls) => root.classList.contains(cls)); - const adElements = queryAllSelectors(this.config.selectors, root); - const hasVisibleAdElement = adElements.some((el) => isVisible(el)); - - const detected = hasAdClass || hasVisibleAdElement; - - return { - detected, - interferenceType: 'youtubeAds', - results: detected - ? [ - { - adCurrentlyPlaying: true, - adType: 'video-ad', - source: 'one-time-detection', - }, - ] - : [], - timestamp: Date.now(), - }; - } - - findRoot() { - return document.querySelector(this.config.rootSelector); - } - - checkForInterference() { - if (!this.root) { - return; - } - - const hadAd = this.adCurrentlyPlaying; - const hasAdClass = this.config.adClasses.some((/** @type {string} */ cls) => this.root && this.root.classList.contains(cls)); - this.adCurrentlyPlaying = hasAdClass; - - if (this.onInterferenceChange && hadAd !== this.adCurrentlyPlaying) { - this.onInterferenceChange( - this.adCurrentlyPlaying - ? { - detected: true, - interferenceType: 'youtubeAds', - results: [ - { - adCurrentlyPlaying: true, - adType: 'video-ad', - source: 'detector', - }, - ], - timestamp: Date.now(), - } - : createEmptyResult('youtubeAds'), - ); - } - } -} diff --git a/injected/src/services/web-interference-detection/detector-service.js b/injected/src/services/web-interference-detection/detector-service.js deleted file mode 100644 index 5868c4c6d6..0000000000 --- a/injected/src/services/web-interference-detection/detector-service.js +++ /dev/null @@ -1,81 +0,0 @@ -/** - * @typedef {import('./types/detection.types.js').InterferenceType} InterferenceType - * @typedef {import('./types/detection.types.js').InterferenceConfig} InterferenceConfig - * @typedef {import('./types/detection.types.js').TypeDetectionResult} TypeDetectionResult - * @typedef {import('./types/detection.types.js').DetectionResults} DetectionResults - * @typedef {import('./types/detection.types.js').DetectInterferenceParams} DetectInterferenceParams - * @typedef {import('./types/detection.types.js').InterferenceDetector} InterferenceDetector - * @typedef {import('./types/api.types.js').InterferenceDetectionRequest} InterferenceDetectionRequest - */ - -import { BotDetection } from './detections/bot-detection.js'; -import { FraudDetection } from './detections/fraud-detection.js'; -import { YouTubeAdsDetection } from './detections/youtube-ads-detection.js'; -import { createEmptyResult } from './utils/result-factory.js'; - -const detectionClassMap = { - botDetection: BotDetection, - fraudDetection: FraudDetection, - youtubeAds: YouTubeAdsDetection, -}; - -class WebInterferenceDetectionService { - /** - * @param {DetectInterferenceParams} params - */ - constructor(params) { - const { interferenceConfig, onDetectionChange } = params; - this.interferenceConfig = interferenceConfig; - this.onDetectionChange = onDetectionChange; - this.activeDetections = []; - } - - /** - * @param {InterferenceDetectionRequest} request - * @returns {DetectionResults} - */ - detect(request) { - const { types } = request; - const results = /** @type {DetectionResults} */ ({}); - types.forEach((type) => { - const DetectionClass = detectionClassMap[type]; - if (!DetectionClass) { - throw new Error(`Unsupported interference type: "${type}". Supported types: ${Object.keys(detectionClassMap).join(', ')}`); - } - - const config = this.interferenceConfig.settings?.[type]; - if (!config) { - results[type] = createEmptyResult(type); - return; - } - - const { observeDOMChanges } = config ?? {}; - - const callback = - observeDOMChanges && this.onDetectionChange - ? (/** @type {TypeDetectionResult} */ result) => this.onDetectionChange?.({ [type]: result }) - : null; - - const detection = /** @type {InterferenceDetector} */ (new DetectionClass(config, callback)); - results[type] = detection.detect(); - - if (callback && typeof detection.stop === 'function') { - this.activeDetections.push(detection); - } - }); - - return results; - } - - cleanup() { - this.activeDetections.forEach((detection) => detection.stop()); - } -} - -/** - * @param {DetectInterferenceParams} params - * @returns {WebInterferenceDetectionService} - */ -export function createWebInterferenceService(params) { - return new WebInterferenceDetectionService(params); -} diff --git a/injected/src/services/web-interference-detection/types/api.types.js b/injected/src/services/web-interference-detection/types/api.types.js deleted file mode 100644 index 8ad5a4f180..0000000000 --- a/injected/src/services/web-interference-detection/types/api.types.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * API types for native messaging interface - */ - -/** - * @typedef {import('./detection.types').InterferenceType} InterferenceType - */ - -/** - * @typedef {object} InterferenceDetectionRequest - * @property {InterferenceType[]} types - Interference types to detect - */ - -export {}; diff --git a/injected/src/services/web-interference-detection/types/detection.types.js b/injected/src/services/web-interference-detection/types/detection.types.js deleted file mode 100644 index 6923db39be..0000000000 --- a/injected/src/services/web-interference-detection/types/detection.types.js +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Core detection types for web interference detection service - */ - -/** - * @typedef {'botDetection' | 'youtubeAds' | 'fraudDetection'} InterferenceType - */ - -/** - * @typedef {'cloudflare' | 'hcaptcha'} VendorName - */ - -/** - * @typedef {'turnstile' | 'challengePage'} ChallengeType - */ - -/** - * @typedef {'cloudflareTurnstile' | 'cloudflareChallengePage' | 'hcaptcha'} ChallengeIdentifier - */ - -/** - * @typedef {'enabled' | 'disabled'} FeatureState - */ - -/** - * @typedef {object} StatusSelectorConfig - * @property {string} status - * @property {string[]} [selectors] - * @property {string[]} [textPatterns] - * @property {string[]} [textSources] - */ - -/** - * @typedef {object} ChallengeConfig - * @property {FeatureState} state - * @property {VendorName} vendor - * @property {string[]} [selectors] - * @property {string[]} [windowProperties] - * @property {StatusSelectorConfig[]} [statusSelectors] - * @property {boolean} [observeDOMChanges] - */ - -/** - * @typedef {Partial>} BotDetectionConfig - */ - -/** - * @typedef {object} AntiFraudAlertConfig - * @property {FeatureState} state - * @property {string} type - * @property {string[]} [selectors] - * @property {string[]} [textPatterns] - * @property {string[]} [textSources] - * @property {boolean} [observeDOMChanges] - */ - -/** - * @typedef {Record} AntiFraudConfig - */ - -/** - * @typedef {object} YouTubeAdsConfig - * @property {string} rootSelector - * @property {string[]} watchAttributes - * @property {string[]} selectors - * @property {string[]} adClasses - * @property {string[]} [textPatterns] - * @property {string[]} [textSources] - * @property {number} [pollInterval] - * @property {number} [rerootInterval] - * @property {boolean} [observeDOMChanges] - */ - -/** - * @typedef {object} InterferenceSettings - * @property {BotDetectionConfig} [botDetection] - * @property {AntiFraudConfig} [fraudDetection] - * @property {YouTubeAdsConfig} [youtubeAds] - */ - -/** - * @typedef {object} InterferenceConfig - * @property {InterferenceSettings} settings - */ - -/** - * @typedef {object} VendorDetectionResult - * @property {boolean} detected - Whether vendor was detected - * @property {VendorName} vendor - Vendor identifier - * @property {string} challengeType - Challenge identifier - * @property {string | null} challengeStatus - Challenge status - */ - -/** - * @typedef {object} TypeDetectionResult - * @property {boolean} detected - * @property {InterferenceType} interferenceType - * @property {Record[]} [results] - * @property {number} timestamp - */ - -/** - * @typedef {object} InterferenceDetector - * @property {() => TypeDetectionResult} detect - * @property {() => void} [stop] - */ - -/** - * @callback TypeDetectorFunction - * @param {InterferenceConfig} interferenceConfig - * @returns {TypeDetectionResult} - */ - -/** - * @typedef {object} DetectInterferenceParams - * @property {InterferenceConfig} interferenceConfig - * @property {((result: DetectionResults) => void)|null} [onDetectionChange] - */ - -/** - * @typedef {Partial>} DetectionResults - */ - -export {}; diff --git a/injected/src/services/web-interference-detection/utils/result-factory.js b/injected/src/services/web-interference-detection/utils/result-factory.js deleted file mode 100644 index 60a02205cd..0000000000 --- a/injected/src/services/web-interference-detection/utils/result-factory.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @typedef {import('../types/detection.types.js').InterferenceType} InterferenceType - * @typedef {import('../types/detection.types.js').TypeDetectionResult} TypeDetectionResult - */ - -/** - * @param {InterferenceType} type - * @returns {TypeDetectionResult} - */ -export function createEmptyResult(type) { - return { - detected: false, - interferenceType: type, - results: [], - timestamp: Date.now(), - }; -} From f241a6dfad4bcbf2ae2694e9973d442e8f38e342 Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 14:26:22 -0700 Subject: [PATCH 03/13] init and service --- injected/src/content-scope-features.js | 4 ++ injected/src/detectors/detector-init.js | 44 ++++++++++++++++ injected/src/detectors/detector-service.js | 57 ++++++++++++--------- injected/src/features.js | 1 + injected/src/features/breakage-reporting.js | 10 ++++ 5 files changed, 92 insertions(+), 24 deletions(-) create mode 100644 injected/src/detectors/detector-init.js diff --git a/injected/src/content-scope-features.js b/injected/src/content-scope-features.js index 2b062f6c23..6cb73beb1b 100644 --- a/injected/src/content-scope-features.js +++ b/injected/src/content-scope-features.js @@ -3,6 +3,7 @@ import { platformSupport } from './features'; import { PerformanceMonitor } from './performance'; import platformFeatures from 'ddg:platformFeatures'; import { registerForURLChanges } from './url-change'; +import { initDetectors } from './detectors/detector-init.js'; let initArgs = null; const updates = []; @@ -44,6 +45,9 @@ export function load(args) { const bundledFeatureNames = typeof importConfig.injectName === 'string' ? platformSupport[importConfig.injectName] : []; + // Initialize detectors early so they're available when features init + initDetectors(args.bundledConfig); + // prettier-ignore const featuresToLoad = isGloballyDisabled(args) // if we're globally disabled, only allow `platformSpecificFeatures` diff --git a/injected/src/detectors/detector-init.js b/injected/src/detectors/detector-init.js new file mode 100644 index 0000000000..b90f736bd0 --- /dev/null +++ b/injected/src/detectors/detector-init.js @@ -0,0 +1,44 @@ +/** + * Detector Initialization + * + * Reads bundledConfig and registers detectors with the detector service. + * Called during content-scope-features load phase. + */ + +import { registerDetector } from './detector-service.js'; +import { DEFAULT_DETECTOR_SETTINGS } from './default-config.js'; +import { createBotDetector } from './detections/bot-detection.js'; +import { createFraudDetector } from './detections/fraud-detection.js'; +import { createYouTubeAdsDetector } from './detections/youtube-ads-detection.js'; + +/** + * Initialize detectors based on bundled configuration + * @param {any} bundledConfig - The bundled configuration object + */ +export function initDetectors(bundledConfig) { + // Check if web-interference-detection feature is enabled + const enabled = bundledConfig?.features?.['web-interference-detection']?.state === 'enabled'; + if (!enabled) { + return; + } + + // Merge default settings with remote config + const detectorSettings = { + ...DEFAULT_DETECTOR_SETTINGS, + ...bundledConfig?.features?.['web-interference-detection']?.settings?.interferenceTypes, + }; + + // Register each detector if its settings exist + if (detectorSettings.botDetection) { + registerDetector('botDetection', createBotDetector(detectorSettings.botDetection)); + } + + if (detectorSettings.fraudDetection) { + registerDetector('fraudDetection', createFraudDetector(detectorSettings.fraudDetection)); + } + + if (detectorSettings.youtubeAds) { + registerDetector('youtubeAds', createYouTubeAdsDetector(detectorSettings.youtubeAds)); + } +} + diff --git a/injected/src/detectors/detector-service.js b/injected/src/detectors/detector-service.js index 522762a413..a6642a0666 100644 --- a/injected/src/detectors/detector-service.js +++ b/injected/src/detectors/detector-service.js @@ -1,30 +1,37 @@ /** - * @typedef {object} DetectorRegistration - * @property {() => any | Promise} getData - * @property {(() => any | Promise)=} refresh - * @property {(() => void)=} teardown + * Detector Service * - * @typedef {object} CachedSnapshot - * @property {any} data - * @property {number} ts + * Central registry and caching layer for interference detectors. + * Provides a simple API for registering detectors and retrieving their data. */ const registrations = new Map(); const cache = new Map(); /** - * Register a detector with the shared service. - * Subsequent calls replace the previous registration for the same id. - * @param {string} detectorId - * @param {DetectorRegistration} registration + * @typedef {Object} DetectorRegistration + * @property {() => Promise} getData - Function to get current detector data + * @property {() => Promise} [refresh] - Optional function to refresh/re-run detection + */ + +/** + * @typedef {Object} CachedSnapshot + * @property {any} data - The cached detector data + * @property {number} ts - Timestamp when data was cached + */ + +/** + * Register a detector with the service + * @param {string} detectorId - Unique identifier for the detector + * @param {DetectorRegistration} registration - Detector registration object */ export function registerDetector(detectorId, registration) { registrations.set(detectorId, registration); } /** - * Remove a detector registration and drop any cached data. - * @param {string} detectorId + * Unregister a detector from the service + * @param {string} detectorId - Unique identifier for the detector */ export function unregisterDetector(detectorId) { const registration = registrations.get(detectorId); @@ -34,8 +41,8 @@ export function unregisterDetector(detectorId) { } /** - * Reset all detector caches and invoke teardowns. - * @param {string} [reason] + * Reset all detectors and clear cache + * @param {string} [reason] - Optional reason for reset */ export function resetDetectors(reason = 'manual') { for (const registration of registrations.values()) { @@ -45,14 +52,16 @@ export function resetDetectors(reason = 'manual') { } /** - * Fetch detector data. Uses cached value when available unless maxAgeMs is exceeded. - * @param {string} detectorId - * @param {{ maxAgeMs?: number }} [options] - * @returns {Promise} + * Get data from a specific detector + * @param {string} detectorId - Unique identifier for the detector + * @param {Object} [options] - Options for data retrieval + * @param {number} [options.maxAgeMs] - Maximum age of cached data in milliseconds + * @returns {Promise} Detector data or null if not registered */ export async function getDetectorData(detectorId, options = {}) { const { maxAgeMs } = options; const cached = /** @type {CachedSnapshot | undefined} */ (cache.get(detectorId)); + if (cached) { const age = Date.now() - cached.ts; if (!maxAgeMs || age <= maxAgeMs) { @@ -77,10 +86,11 @@ export async function getDetectorData(detectorId, options = {}) { } /** - * Convenience helper for fetching multiple detectors at once. - * @param {string[]} detectorIds - * @param {{ maxAgeMs?: number }} [options] - * @returns {Promise>} + * Get data from multiple detectors in a single call + * @param {string[]} detectorIds - Array of detector IDs + * @param {Object} [options] - Options for data retrieval + * @param {number} [options.maxAgeMs] - Maximum age of cached data in milliseconds + * @returns {Promise>} Object mapping detector IDs to their data */ export async function getDetectorBatch(detectorIds, options = {}) { const results = {}; @@ -89,4 +99,3 @@ export async function getDetectorBatch(detectorIds, options = {}) { } return results; } - diff --git a/injected/src/features.js b/injected/src/features.js index f704269a41..046a219a5e 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -32,6 +32,7 @@ const otherFeatures = /** @type {const} */ ([ 'favicon', 'webTelemetry', 'pageContext', + 'webInterferenceDetection', ]); /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index b94172bfa7..b38668c8d4 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -1,5 +1,6 @@ import ContentFeature from '../content-feature'; import { getExpandedPerformanceMetrics, getJsPerformanceMetrics } from './breakage-reporting/utils.js'; +import { getDetectorBatch } from '../detectors/detector-service.js'; export default class BreakageReporting extends ContentFeature { init() { @@ -7,9 +8,18 @@ export default class BreakageReporting extends ContentFeature { this.messaging.subscribe('getBreakageReportValues', async () => { const jsPerformance = getJsPerformanceMetrics(); const referrer = document.referrer; + + // Collect detector data + const detectorData = await getDetectorBatch([ + 'botDetection', + 'fraudDetection', + 'youtubeAds' + ]); + const result = { jsPerformance, referrer, + detectorData, }; if (isExpandedPerformanceMetricsEnabled) { const expandedPerformanceMetrics = await getExpandedPerformanceMetrics(); From 5653ae38576bb121b4a89549065cb6afde16ad87 Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 14:29:48 -0700 Subject: [PATCH 04/13] update readme --- injected/src/detectors/README.md | 96 +++++++++++++++++++++++--------- 1 file changed, 71 insertions(+), 25 deletions(-) diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md index 7360b474bc..866de90ee8 100644 --- a/injected/src/detectors/README.md +++ b/injected/src/detectors/README.md @@ -1,8 +1,8 @@ -# Detector Registry (Prototype) +# Detector Service -This directory contains a lightweight registry that runs inside content-scope-scripts. Detectors register with the shared service and any feature can query their latest results (breakage reporting, native PIR, debug tooling, etc.). +This directory contains a lightweight detector service that runs inside content-scope-scripts. Detectors are automatically registered during the `load()` phase and any feature can query their latest results (breakage reporting, native PIR, debug tooling, etc.). -The initial focus is synchronous, on-demand collection. Continuous monitoring (mutation observers, polling, batching) can be layered on later without changing the public API. +The current implementation focuses on synchronous, on-demand collection with caching. Continuous monitoring (mutation observers, polling, batching) can be layered on later without changing the public API. ## API Snapshot @@ -33,39 +33,85 @@ Detectors return arbitrary JSON payloads. Include timestamps if consumers rely o ``` detectors/ -├── detector-service.js # registry + caching helpers -├── default-config.js # sample configuration blobs +├── detector-service.js # registry + caching service +├── detector-init.js # initializes detectors from bundledConfig +├── default-config.js # default detector settings ├── detections/ -│ ├── bot-detection.js # helpers for CAPTCHA/bot detection -│ ├── fraud-detection.js # helpers for anti-fraud banners -│ └── youtube-ads-detection.js # helper for YouTube ad snapshots -├── detections/detection-base.js # optional base for observer-style detectors +│ ├── bot-detection.js # CAPTCHA/bot detection +│ ├── fraud-detection.js # anti-fraud/phishing warnings +│ ├── youtube-ads-detection.js # YouTube ad detection +│ └── detection-base.js # optional base for observer-style detectors └── utils/ └── detection-utils.js # DOM helpers (selectors, text matching, visibility) ``` -## Example Usage +## How It Works -```javascript -import { registerDetector, getDetectorData } from '../detectors/detector-service.js'; -import { createBotDetector } from '../detectors/detections/bot-detection.js'; -import { DEFAULT_DETECTOR_SETTINGS } from '../detectors/default-config.js'; +### Initialization + +Detectors are automatically registered during the content-scope-features `load()` phase: + +1. `content-scope-features.js` calls `initDetectors(bundledConfig)` +2. `detector-init.js` reads the `web-interference-detection` feature config +3. Default detector settings are merged with remote config +4. Detectors are registered with the service using `registerDetector()` + +### Remote Configuration -// During feature init -registerDetector('botDetection', createBotDetector(DEFAULT_DETECTOR_SETTINGS.botDetection)); +Detectors are controlled via `privacy-configuration/features/web-interference-detection.json`: -// Later, when preparing a breakage report -const snapshot = await getDetectorData('botDetection', { maxAgeMs: 1_000 }); -if (snapshot?.detected) { - payload.detectors.bot = snapshot; +```json +{ + "state": "enabled", + "settings": { + "interferenceTypes": { + "botDetection": { + "hcaptcha": { + "state": "enabled", + "vendor": "hcaptcha", + "selectors": [".h-captcha"], + "windowProperties": ["hcaptcha"] + } + } + } + } } ``` -## Extending +### Consuming Detector Data + +Features can directly import and use the detector service: + +```javascript +import { getDetectorBatch } from '../detectors/detector-service.js'; + +// In breakage reporting feature +const detectorData = await getDetectorBatch(['botDetection', 'fraudDetection', 'youtubeAds']); +// Returns: { botDetection: {...}, fraudDetection: {...}, youtubeAds: {...} } +``` + +## Adding New Detectors + +1. **Create detection logic** under `detections/`: + - Export a `createXDetector(config)` factory function + - Return an object with `{ getData, refresh?, teardown? }` + - Use shared utilities from `utils/detection-utils.js` + +2. **Add default config** to `default-config.js`: + - Define default selectors, patterns, and settings + - These serve as fallback if remote config is unavailable + +3. **Register in detector-init.js**: + - Import your detector factory + - Add registration logic in `initDetectors()` + +4. **Add remote config** to `privacy-configuration/features/web-interference-detection.json`: + - Define the detector's configuration schema + - This allows remote enabling/disabling and tuning -1. Add a helper under `detections/` that exposes a `createXDetector(config)` returning `{ getData, refresh?, teardown? }`. -2. Register it during feature bootstrap or via a shared initializer. -3. (Optional) Add defaults to `default-config.js` or wire it to remote config. +5. **Consume the detector** in your feature: + - Import `getDetectorData` or `getDetectorBatch` + - Call with your detector ID to get results -Future enhancements—shared observers, background aggregation, streaming updates—can build on this registry without breaking the public API. +Future enhancements—shared observers, background aggregation, streaming updates—can build on this service without breaking the public API. From be56f76ce59821b722c70943580225a6569adbda Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 15:33:51 -0700 Subject: [PATCH 05/13] update readme --- injected/src/detectors/README.md | 111 ++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 3 deletions(-) diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md index 866de90ee8..3f4db9e248 100644 --- a/injected/src/detectors/README.md +++ b/injected/src/detectors/README.md @@ -42,7 +42,7 @@ detectors/ │ ├── youtube-ads-detection.js # YouTube ad detection │ └── detection-base.js # optional base for observer-style detectors └── utils/ - └── detection-utils.js # DOM helpers (selectors, text matching, visibility) + └── detection-utils.js # DOM helpers (selectors, text matching, visibility, domain matching) ``` ## How It Works @@ -64,6 +64,7 @@ Detectors are controlled via `privacy-configuration/features/web-interference-de { "state": "enabled", "settings": { + "domains": [], "interferenceTypes": { "botDetection": { "hcaptcha": { @@ -72,12 +73,71 @@ Detectors are controlled via `privacy-configuration/features/web-interference-de "selectors": [".h-captcha"], "windowProperties": ["hcaptcha"] } + }, + "youtubeAds": { + "domains": ["*.youtube.com", "youtube.com"], + "rootSelector": "#movie_player" } } } } ``` +#### Domain Gating + +Detectors can be restricted to specific domains using the `domains` field: + +- **Global domains** (`settings.domains`): Apply to all detectors unless overridden +- **Per-detector domains** (`interferenceTypes.{detectorId}.domains`): Override global setting +- **Domain patterns**: + - Exact match: `"youtube.com"` + - Wildcard: `"*.youtube.com"` (matches www.youtube.com, m.youtube.com, etc.) + - Substring: `"youtube.com"` also matches `www.youtube.com` for convenience +- **Empty array** (`[]`): Run on all domains (default) + +Examples: +```json +// Run bot detection only on banking sites +"botDetection": { + "domains": ["*.chase.com", "*.bankofamerica.com"], + ... +} + +// Run YouTube detector only on YouTube +"youtubeAds": { + "domains": ["*.youtube.com"], + ... +} +``` + +#### Auto-Run + +Detectors can be configured to run automatically on page load: + +- **`autoRun: true`** (default): Run detector automatically after page load + - Gates are checked (domain + custom `shouldRun()`) + - Results are cached immediately + - Runs after 100ms delay to let DOM settle + - Useful for detectors that should always gather data (bot detection, fraud detection) + +- **`autoRun: false`**: Only run when explicitly called + - Gates are skipped for manual calls + - Useful for expensive detectors or event-driven scenarios + - Example: YouTube detector only runs when explicitly requested + +Example: +```json +"botDetection": { + "autoRun": true, // Run automatically with gates + "domains": ["*.example.com"], + ... +}, +"expensiveDetector": { + "autoRun": false, // Only run on-demand, skip gates + ... +} +``` + ### Consuming Detector Data Features can directly import and use the detector service: @@ -85,16 +145,24 @@ Features can directly import and use the detector service: ```javascript import { getDetectorBatch } from '../detectors/detector-service.js'; -// In breakage reporting feature +// In breakage reporting feature - gates bypassed automatically for manual calls const detectorData = await getDetectorBatch(['botDetection', 'fraudDetection', 'youtubeAds']); // Returns: { botDetection: {...}, fraudDetection: {...}, youtubeAds: {...} } ``` +**Behavior:** +- **Manual calls** (like above): Gates are bypassed, detector always runs +- **Auto-run calls**: Gates are checked (domain + `shouldRun()`) +- **Caching**: Results cached with timestamp, use `maxAgeMs` to force refresh + +**Options:** +- `maxAgeMs`: Maximum age of cached data in milliseconds before forcing refresh + ## Adding New Detectors 1. **Create detection logic** under `detections/`: - Export a `createXDetector(config)` factory function - - Return an object with `{ getData, refresh?, teardown? }` + - Return an object with `{ getData, shouldRun?, refresh?, teardown? }` - Use shared utilities from `utils/detection-utils.js` 2. **Add default config** to `default-config.js`: @@ -107,11 +175,48 @@ const detectorData = await getDetectorBatch(['botDetection', 'fraudDetection', ' 4. **Add remote config** to `privacy-configuration/features/web-interference-detection.json`: - Define the detector's configuration schema + - Optionally add `domains` field for domain gating - This allows remote enabling/disabling and tuning 5. **Consume the detector** in your feature: - Import `getDetectorData` or `getDetectorBatch` - Call with your detector ID to get results +### Custom Gate Functions + +Detectors can optionally implement a `shouldRun()` gate function for custom precondition checks: + +```javascript +export function createMyDetector(config) { + return { + // Optional gate function runs before getData() + // Return false to skip detection entirely (returns null) + shouldRun() { + // Example: Only run if specific element exists + return document.querySelector('#app-root') !== null; + }, + + async getData() { + // This only runs if shouldRun() returns true + // and domain gate passes + return { detected: true, ... }; + } + }; +} +``` + +**When to use `shouldRun()`:** +- Lightweight DOM precondition checks (e.g., element exists) +- Dependency on another detector's results +- Runtime feature detection +- Performance optimization to avoid expensive operations + +**Gate execution order:** +1. Domain gate (from config) +2. Custom `shouldRun()` gate (if provided) +3. `getData()` (if all gates pass) + +If any gate fails, `getDetectorData()` returns `null`. + Future enhancements—shared observers, background aggregation, streaming updates—can build on this service without breaking the public API. From e2d83175915f353849ecb68795bb503069a308c4 Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 15:44:24 -0700 Subject: [PATCH 06/13] update readme --- injected/src/detectors/README.md | 53 ++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md index 3f4db9e248..6d4357b44b 100644 --- a/injected/src/detectors/README.md +++ b/injected/src/detectors/README.md @@ -65,6 +65,7 @@ Detectors are controlled via `privacy-configuration/features/web-interference-de "state": "enabled", "settings": { "domains": [], + "autoRunDelayMs": 100, "interferenceTypes": { "botDetection": { "hcaptcha": { @@ -117,7 +118,7 @@ Detectors can be configured to run automatically on page load: - **`autoRun: true`** (default): Run detector automatically after page load - Gates are checked (domain + custom `shouldRun()`) - Results are cached immediately - - Runs after 100ms delay to let DOM settle + - Runs after configurable delay (see `autoRunDelayMs`) - Useful for detectors that should always gather data (bot detection, fraud detection) - **`autoRun: false`**: Only run when explicitly called @@ -125,16 +126,26 @@ Detectors can be configured to run automatically on page load: - Useful for expensive detectors or event-driven scenarios - Example: YouTube detector only runs when explicitly requested +- **`autoRunDelayMs`** (global setting, default: 100): Milliseconds to wait before running auto-run detectors + - Allows DOM to settle after page load + - Can be tuned per-site or globally + - Use 0 for immediate execution, higher values for slower-loading pages + Example: ```json -"botDetection": { - "autoRun": true, // Run automatically with gates - "domains": ["*.example.com"], - ... -}, -"expensiveDetector": { - "autoRun": false, // Only run on-demand, skip gates - ... +"settings": { + "autoRunDelayMs": 250, // Wait 250ms before auto-running detectors + "interferenceTypes": { + "botDetection": { + "autoRun": true, // Run automatically with gates + "domains": ["*.example.com"], + ... + }, + "expensiveDetector": { + "autoRun": false, // Only run on-demand, skip gates + ... + } + } } ``` @@ -205,9 +216,31 @@ export function createMyDetector(config) { } ``` +**Example: Detector that depends on another detector's results** + +```javascript +import { getDetectorData } from '../detector-service.js'; + +export function createAdvancedBotDetector(config) { + return { + // Only run advanced detection if basic bot detection found something + async shouldRun() { + const basicBotData = await getDetectorData('botDetection'); + // Only run if basic detector found a bot/CAPTCHA + return basicBotData?.detected === true; + }, + + async getData() { + // Run expensive/detailed analysis only when needed + return runAdvancedBotAnalysis(config); + } + }; +} +``` + **When to use `shouldRun()`:** - Lightweight DOM precondition checks (e.g., element exists) -- Dependency on another detector's results +- Dependency on another detector's results (use `getDetectorData()` inside `shouldRun()`) - Runtime feature detection - Performance optimization to avoid expensive operations From f5abfad15b5fbfde04a2e9fcfa66601ff96cce4c Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 15:49:06 -0700 Subject: [PATCH 07/13] add auto run --- .../detections/youtube-ads-detection.js | 10 ++ injected/src/detectors/detector-init.js | 110 ++++++++++++++++-- injected/src/detectors/detector-service.js | 5 +- .../src/detectors/utils/detection-utils.js | 36 ++++++ injected/src/features/breakage-reporting.js | 2 +- 5 files changed, 153 insertions(+), 10 deletions(-) diff --git a/injected/src/detectors/detections/youtube-ads-detection.js b/injected/src/detectors/detections/youtube-ads-detection.js index e51b687fdb..7c8a6f89f9 100644 --- a/injected/src/detectors/detections/youtube-ads-detection.js +++ b/injected/src/detectors/detections/youtube-ads-detection.js @@ -8,6 +8,16 @@ const DEFAULT_CONFIG = { export function createYouTubeAdsDetector(config = {}) { const mergedConfig = { ...DEFAULT_CONFIG, ...config }; return { + /** + * Optional gate function - return false to skip detection entirely + * This runs before getData() and can be used for lightweight precondition checks + */ + shouldRun() { + // Only run if the YouTube player root element exists + // This avoids unnecessary DOM scanning on non-video pages + return document.querySelector(mergedConfig.rootSelector) !== null; + }, + async getData() { return runYouTubeAdsDetection(mergedConfig); }, diff --git a/injected/src/detectors/detector-init.js b/injected/src/detectors/detector-init.js index b90f736bd0..a2c3ddf63f 100644 --- a/injected/src/detectors/detector-init.js +++ b/injected/src/detectors/detector-init.js @@ -10,6 +10,71 @@ import { DEFAULT_DETECTOR_SETTINGS } from './default-config.js'; import { createBotDetector } from './detections/bot-detection.js'; import { createFraudDetector } from './detections/fraud-detection.js'; import { createYouTubeAdsDetector } from './detections/youtube-ads-detection.js'; +import { matchesDomainPatterns } from './utils/detection-utils.js'; + +/** + * Check if gates should run for this call + * @param {Object} options - Options passed to getData + * @param {boolean} [options._autoRun] - Internal flag indicating this is an auto-run call + * @param {string[]} [domains] - Domain patterns to check + * @param {Function} [shouldRun] - Custom gate function + * @returns {boolean} True if gates pass, false otherwise + */ +function checkGates(options, domains, shouldRun) { + // Only check gates for auto-run calls + // Manual calls bypass gates by default + if (!options?._autoRun) { + return true; + } + + // Check domain gate + if (!matchesDomainPatterns(domains)) { + return false; + } + + // Check custom gate function if provided + if (shouldRun && !shouldRun()) { + return false; + } + + return true; +} + +/** + * Create a gated detector registration that checks domain and custom gates + * Gates only apply for auto-run calls (when _autoRun: true is passed) + * Manual calls bypass gates by default + * + * @param {Object} registration - The detector registration object + * @param {Function} registration.getData - Function to get detector data + * @param {Function} [registration.shouldRun] - Optional gate function + * @param {Function} [registration.refresh] - Optional refresh function + * @param {Function} [registration.teardown] - Optional teardown function + * @param {string[]} [domains] - Optional array of domain patterns + * @returns {Object} Gated detector registration + */ +function createGatedDetector(registration, domains) { + const { getData, shouldRun, refresh, teardown } = registration; + + return { + getData: async (options) => { + // Check gates (only for auto-run, manual calls bypass) + if (!checkGates(options, domains, shouldRun)) { + return null; + } + + // All gates passed, run the detector + return getData(); + }, + refresh: refresh ? async (options) => { + if (!checkGates(options, domains, shouldRun)) { + return null; + } + return refresh(); + } : undefined, + teardown, + }; +} /** * Initialize detectors based on bundled configuration @@ -28,17 +93,46 @@ export function initDetectors(bundledConfig) { ...bundledConfig?.features?.['web-interference-detection']?.settings?.interferenceTypes, }; + // Get global domains config (applies to all detectors unless overridden) + const globalDomains = bundledConfig?.features?.['web-interference-detection']?.settings?.domains; + + // Track detectors to auto-run after registration + const autoRunDetectors = []; + + // Helper to register a detector with less repetition + const registerIfEnabled = (detectorId, detectorSettings, createDetectorFn) => { + if (!detectorSettings) return; + + const domains = detectorSettings.domains || globalDomains; + const autoRun = detectorSettings.autoRun !== false; // Default true + const registration = createDetectorFn(detectorSettings); + + registerDetector(detectorId, createGatedDetector(registration, domains)); + + if (autoRun) { + autoRunDetectors.push(detectorId); + } + }; + // Register each detector if its settings exist - if (detectorSettings.botDetection) { - registerDetector('botDetection', createBotDetector(detectorSettings.botDetection)); - } + registerIfEnabled('botDetection', detectorSettings.botDetection, createBotDetector); + registerIfEnabled('fraudDetection', detectorSettings.fraudDetection, createFraudDetector); + registerIfEnabled('youtubeAds', detectorSettings.youtubeAds, createYouTubeAdsDetector); - if (detectorSettings.fraudDetection) { - registerDetector('fraudDetection', createFraudDetector(detectorSettings.fraudDetection)); - } + // Auto-run detectors after a short delay to let page settle + if (autoRunDetectors.length > 0) { + // Get delay from config, default to 100ms + const autoRunDelayMs = bundledConfig?.features?.['web-interference-detection']?.settings?.autoRunDelayMs ?? 100; + + // Use setTimeout to avoid blocking page load + setTimeout(async () => { + const { getDetectorBatch } = await import('./detector-service.js'); + + // Run all auto-run detectors with _autoRun flag (gates will be checked) + await getDetectorBatch(autoRunDetectors, { _autoRun: true }); - if (detectorSettings.youtubeAds) { - registerDetector('youtubeAds', createYouTubeAdsDetector(detectorSettings.youtubeAds)); + console.log('[detectors] Auto-run complete for:', autoRunDetectors); + }, autoRunDelayMs); } } diff --git a/injected/src/detectors/detector-service.js b/injected/src/detectors/detector-service.js index a6642a0666..bc7946b781 100644 --- a/injected/src/detectors/detector-service.js +++ b/injected/src/detectors/detector-service.js @@ -56,6 +56,7 @@ export function resetDetectors(reason = 'manual') { * @param {string} detectorId - Unique identifier for the detector * @param {Object} [options] - Options for data retrieval * @param {number} [options.maxAgeMs] - Maximum age of cached data in milliseconds + * @param {boolean} [options._autoRun] - Internal flag indicating auto-run (gates apply) * @returns {Promise} Detector data or null if not registered */ export async function getDetectorData(detectorId, options = {}) { @@ -76,7 +77,8 @@ export async function getDetectorData(detectorId, options = {}) { const runner = registration.refresh ?? registration.getData; try { - const data = await runner(); + // Pass options to the runner so gates can check _autoRun flag + const data = await runner(options); cache.set(detectorId, { data, ts: Date.now() }); return data; } catch (error) { @@ -90,6 +92,7 @@ export async function getDetectorData(detectorId, options = {}) { * @param {string[]} detectorIds - Array of detector IDs * @param {Object} [options] - Options for data retrieval * @param {number} [options.maxAgeMs] - Maximum age of cached data in milliseconds + * @param {boolean} [options._autoRun] - Internal flag indicating auto-run (gates apply) * @returns {Promise>} Object mapping detector IDs to their data */ export async function getDetectorBatch(detectorIds, options = {}) { diff --git a/injected/src/detectors/utils/detection-utils.js b/injected/src/detectors/utils/detection-utils.js index 7419596b7d..7c63d2ce89 100644 --- a/injected/src/detectors/utils/detection-utils.js +++ b/injected/src/detectors/utils/detection-utils.js @@ -115,3 +115,39 @@ export function queryAllSelectors(selectors, root = document) { const elements = root.querySelectorAll(selectors.join(',')); return Array.from(elements); } + +/** + * Check if current domain matches any patterns in the domains list + * Supports exact matches, wildcards (*.domain.com), and substring matching + * + * @param {string[]} [domains] - Array of domain patterns + * @returns {boolean} True if current domain matches any pattern, or if no patterns provided + * + * @example + * matchesDomainPatterns(['youtube.com']) // Exact or substring match + * matchesDomainPatterns(['*.youtube.com']) // Wildcard match (www.youtube.com, m.youtube.com) + * matchesDomainPatterns([]) // Empty = match all domains + */ +export function matchesDomainPatterns(domains) { + if (!domains || !Array.isArray(domains) || domains.length === 0) { + return true; // No domain restrictions means match all + } + + const hostname = window.location.hostname; + + return domains.some((pattern) => { + // Exact match + if (pattern === hostname) { + return true; + } + + // Wildcard pattern (e.g., "*.youtube.com" or "youtube.*") + if (pattern.includes('*')) { + const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$'); + return regex.test(hostname); + } + + // Substring match for convenience (e.g., "youtube.com" matches "www.youtube.com") + return hostname.includes(pattern); + }); +} diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index b38668c8d4..40ebf13c3a 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -9,7 +9,7 @@ export default class BreakageReporting extends ContentFeature { const jsPerformance = getJsPerformanceMetrics(); const referrer = document.referrer; - // Collect detector data + // Collect detector data (gates bypassed by default for manual calls) const detectorData = await getDetectorBatch([ 'botDetection', 'fraudDetection', From 8096a9cd830182389c7c992b05923a330fcd953f Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 16:02:12 -0700 Subject: [PATCH 08/13] drop global domains option --- injected/src/detectors/README.md | 72 ++++++++++++---------- injected/src/detectors/detector-init.js | 5 +- injected/src/detectors/detector-service.js | 22 ------- 3 files changed, 39 insertions(+), 60 deletions(-) diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md index 6d4357b44b..9cceaed5cc 100644 --- a/injected/src/detectors/README.md +++ b/injected/src/detectors/README.md @@ -8,22 +8,24 @@ The current implementation focuses on synchronous, on-demand collection with cac ```mermaid sequenceDiagram - participant Feature as Breakage Reporting + participant Init as detector-init participant Service as detectorService - participant Detector as YouTubeDetector + participant Detector as BotDetector + participant Feature as Breakage Reporting - Detector->>Service: registerDetector('youtubeAds', { getData }) - Feature->>Service: getDetectorData('youtubeAds') - Service->>Detector: getData() - Detector-->>Service: snapshot - Service-->>Feature: snapshot + Init->>Detector: createBotDetector(config) + Detector-->>Init: { getData, shouldRun } + Init->>Service: registerDetector('botDetection', registration) + Note over Service: Auto-run after 100ms delay + Service->>Detector: getData({ _autoRun: true }) + Detector-->>Service: snapshot (cached) + Feature->>Service: getDetectorData('botDetection') + Service-->>Feature: snapshot (from cache) ``` ### Core helpers -- `registerDetector(detectorId, { getData, refresh?, teardown? })` -- `unregisterDetector(detectorId)` -- `resetDetectors(reason?)` +- `registerDetector(detectorId, { getData, shouldRun?, refresh?, teardown? })` - `getDetectorData(detectorId, { maxAgeMs }?)` - `getDetectorBatch(detectorIds, options?)` @@ -51,10 +53,14 @@ detectors/ Detectors are automatically registered during the content-scope-features `load()` phase: -1. `content-scope-features.js` calls `initDetectors(bundledConfig)` +1. `content-scope-features.js` calls `initDetectors(bundledConfig)` during page load 2. `detector-init.js` reads the `web-interference-detection` feature config 3. Default detector settings are merged with remote config 4. Detectors are registered with the service using `registerDetector()` +5. After `autoRunDelayMs` delay (default 100ms), detectors with `autoRun: true` execute automatically + - This delay lets the DOM settle after initial page load + - Auto-run calls check gates (domain + `shouldRun()`) + - Results are cached for later manual calls ### Remote Configuration @@ -64,7 +70,6 @@ Detectors are controlled via `privacy-configuration/features/web-interference-de { "state": "enabled", "settings": { - "domains": [], "autoRunDelayMs": 100, "interferenceTypes": { "botDetection": { @@ -75,9 +80,12 @@ Detectors are controlled via `privacy-configuration/features/web-interference-de "windowProperties": ["hcaptcha"] } }, - "youtubeAds": { - "domains": ["*.youtube.com", "youtube.com"], - "rootSelector": "#movie_player" + "fraudDetection": { + "phishingWarning": { + "state": "enabled", + "type": "phishing", + "selectors": [".warning-banner"] + } } } } @@ -86,28 +94,24 @@ Detectors are controlled via `privacy-configuration/features/web-interference-de #### Domain Gating -Detectors can be restricted to specific domains using the `domains` field: +Detectors can be restricted to specific domains using a per-detector `domains` field: -- **Global domains** (`settings.domains`): Apply to all detectors unless overridden -- **Per-detector domains** (`interferenceTypes.{detectorId}.domains`): Override global setting - **Domain patterns**: - Exact match: `"youtube.com"` - Wildcard: `"*.youtube.com"` (matches www.youtube.com, m.youtube.com, etc.) - Substring: `"youtube.com"` also matches `www.youtube.com` for convenience -- **Empty array** (`[]`): Run on all domains (default) -Examples: +Example: ```json -// Run bot detection only on banking sites -"botDetection": { - "domains": ["*.chase.com", "*.bankofamerica.com"], - ... -} - -// Run YouTube detector only on YouTube -"youtubeAds": { - "domains": ["*.youtube.com"], - ... +{ + "settings": { + "interferenceTypes": { + "fraudDetection": { + "domains": ["*.bank.com", "*.financial.com"], + ... + } + } + } } ``` @@ -124,12 +128,12 @@ Detectors can be configured to run automatically on page load: - **`autoRun: false`**: Only run when explicitly called - Gates are skipped for manual calls - Useful for expensive detectors or event-driven scenarios - - Example: YouTube detector only runs when explicitly requested - **`autoRunDelayMs`** (global setting, default: 100): Milliseconds to wait before running auto-run detectors - Allows DOM to settle after page load - Can be tuned per-site or globally - Use 0 for immediate execution, higher values for slower-loading pages + - **How it works**: After detectors are registered, a single `setTimeout` schedules all auto-run detectors to execute in batch after the delay Example: ```json @@ -157,8 +161,8 @@ Features can directly import and use the detector service: import { getDetectorBatch } from '../detectors/detector-service.js'; // In breakage reporting feature - gates bypassed automatically for manual calls -const detectorData = await getDetectorBatch(['botDetection', 'fraudDetection', 'youtubeAds']); -// Returns: { botDetection: {...}, fraudDetection: {...}, youtubeAds: {...} } +const detectorData = await getDetectorBatch(['botDetection', 'fraudDetection']); +// Returns: { botDetection: {...}, fraudDetection: {...} } ``` **Behavior:** @@ -182,7 +186,7 @@ const detectorData = await getDetectorBatch(['botDetection', 'fraudDetection', ' 3. **Register in detector-init.js**: - Import your detector factory - - Add registration logic in `initDetectors()` + - Add one line: `registerIfEnabled('myDetector', detectorSettings.myDetector, createMyDetector)` 4. **Add remote config** to `privacy-configuration/features/web-interference-detection.json`: - Define the detector's configuration schema diff --git a/injected/src/detectors/detector-init.js b/injected/src/detectors/detector-init.js index a2c3ddf63f..73b4897f92 100644 --- a/injected/src/detectors/detector-init.js +++ b/injected/src/detectors/detector-init.js @@ -93,9 +93,6 @@ export function initDetectors(bundledConfig) { ...bundledConfig?.features?.['web-interference-detection']?.settings?.interferenceTypes, }; - // Get global domains config (applies to all detectors unless overridden) - const globalDomains = bundledConfig?.features?.['web-interference-detection']?.settings?.domains; - // Track detectors to auto-run after registration const autoRunDetectors = []; @@ -103,7 +100,7 @@ export function initDetectors(bundledConfig) { const registerIfEnabled = (detectorId, detectorSettings, createDetectorFn) => { if (!detectorSettings) return; - const domains = detectorSettings.domains || globalDomains; + const domains = detectorSettings.domains; const autoRun = detectorSettings.autoRun !== false; // Default true const registration = createDetectorFn(detectorSettings); diff --git a/injected/src/detectors/detector-service.js b/injected/src/detectors/detector-service.js index bc7946b781..a551e98ec6 100644 --- a/injected/src/detectors/detector-service.js +++ b/injected/src/detectors/detector-service.js @@ -29,28 +29,6 @@ export function registerDetector(detectorId, registration) { registrations.set(detectorId, registration); } -/** - * Unregister a detector from the service - * @param {string} detectorId - Unique identifier for the detector - */ -export function unregisterDetector(detectorId) { - const registration = registrations.get(detectorId); - registration?.teardown?.(); - registrations.delete(detectorId); - cache.delete(detectorId); -} - -/** - * Reset all detectors and clear cache - * @param {string} [reason] - Optional reason for reset - */ -export function resetDetectors(reason = 'manual') { - for (const registration of registrations.values()) { - registration.teardown?.(reason); - } - cache.clear(); -} - /** * Get data from a specific detector * @param {string} detectorId - Unique identifier for the detector From cfe918c9602855015138f5f058f75ced1ccc332d Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 16:11:28 -0700 Subject: [PATCH 09/13] update readme --- injected/src/detectors/README.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md index 9cceaed5cc..c99d7734b9 100644 --- a/injected/src/detectors/README.md +++ b/injected/src/detectors/README.md @@ -8,19 +8,14 @@ The current implementation focuses on synchronous, on-demand collection with cac ```mermaid sequenceDiagram - participant Init as detector-init + participant Feature as Content Feature participant Service as detectorService - participant Detector as BotDetector - participant Feature as Breakage Reporting - - Init->>Detector: createBotDetector(config) - Detector-->>Init: { getData, shouldRun } - Init->>Service: registerDetector('botDetection', registration) - Note over Service: Auto-run after 100ms delay - Service->>Detector: getData({ _autoRun: true }) - Detector-->>Service: snapshot (cached) + participant Detector as botDetection + Feature->>Service: getDetectorData('botDetection') - Service-->>Feature: snapshot (from cache) + Service->>Detector: getData() + Detector-->>Service: snapshot + Service-->>Feature: snapshot (cached) ``` ### Core helpers From 45b61154d800a350c5204a3c3c4e32c47037df8c Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Mon, 10 Nov 2025 16:37:23 -0700 Subject: [PATCH 10/13] remove old file --- injected/src/features.js | 1 - .../features/web-interference-detection.js | 56 ------------------- 2 files changed, 57 deletions(-) delete mode 100644 injected/src/features/web-interference-detection.js diff --git a/injected/src/features.js b/injected/src/features.js index 046a219a5e..f704269a41 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -32,7 +32,6 @@ const otherFeatures = /** @type {const} */ ([ 'favicon', 'webTelemetry', 'pageContext', - 'webInterferenceDetection', ]); /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ diff --git a/injected/src/features/web-interference-detection.js b/injected/src/features/web-interference-detection.js deleted file mode 100644 index 5661d34a53..0000000000 --- a/injected/src/features/web-interference-detection.js +++ /dev/null @@ -1,56 +0,0 @@ -import ContentFeature from '../content-feature.js'; -import { registerDetector, getDetectorBatch, resetDetectors } from '../detectors/detector-service.js'; -import { DEFAULT_DETECTOR_SETTINGS } from '../detectors/default-config.js'; -import { createBotDetector } from '../detectors/detections/bot-detection.js'; -import { createFraudDetector } from '../detectors/detections/fraud-detection.js'; -import { createYouTubeAdsDetector } from '../detectors/detections/youtube-ads-detection.js'; - -export default class WebInterferenceDetection extends ContentFeature { - init() { - const featureEnabled = this.getFeatureSettingEnabled('state'); - if (!featureEnabled) { - return; - } - - const detectorSettings = { - ...DEFAULT_DETECTOR_SETTINGS, - ...this.getFeatureAttr('interferenceTypes', {}), - }; - - this._registerDefaults(detectorSettings); - - this.messaging.subscribe('detectInterference', async (params = {}) => { - try { - const detectorIds = normalizeTypes(params.types); - const results = await getDetectorBatch(detectorIds); - return this.messaging.notify('interferenceDetected', { results }); - } catch (error) { - console.error('[WebInterferenceDetection] Detection failed:', error); - return this.messaging.notify('interferenceDetectionError', { error: error.toString() }); - } - }); - } - - destroy() { - resetDetectors('feature-destroyed'); - } - - _registerDefaults(settings) { - if (settings.botDetection) { - registerDetector('botDetection', createBotDetector(settings.botDetection)); - } - if (settings.fraudDetection) { - registerDetector('fraudDetection', createFraudDetector(settings.fraudDetection)); - } - if (settings.youtubeAds) { - registerDetector('youtubeAds', createYouTubeAdsDetector(settings.youtubeAds)); - } - } -} - -function normalizeTypes(types) { - if (!Array.isArray(types) || types.length === 0) { - return ['botDetection', 'fraudDetection', 'youtubeAds']; - } - return types.filter((type) => typeof type === 'string'); -} From 6f45c6e736a5a73b166817e8821b8523278f5c4e Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Wed, 12 Nov 2025 08:34:08 -0700 Subject: [PATCH 11/13] pr review changes, drop yt detector --- injected/src/content-scope-features.js | 7 +- injected/src/detectors/README.md | 10 ++- injected/src/detectors/default-config.js | 5 -- .../detectors/detections/detection-base.js | 71 ------------------- .../detections/youtube-ads-detection.js | 62 ---------------- injected/src/detectors/detector-init.js | 6 +- injected/src/detectors/detector-service.js | 8 ++- injected/src/features/breakage-reporting.js | 7 +- 8 files changed, 20 insertions(+), 156 deletions(-) delete mode 100644 injected/src/detectors/detections/detection-base.js delete mode 100644 injected/src/detectors/detections/youtube-ads-detection.js diff --git a/injected/src/content-scope-features.js b/injected/src/content-scope-features.js index 6cb73beb1b..f27e01262a 100644 --- a/injected/src/content-scope-features.js +++ b/injected/src/content-scope-features.js @@ -46,7 +46,12 @@ export function load(args) { const bundledFeatureNames = typeof importConfig.injectName === 'string' ? platformSupport[importConfig.injectName] : []; // Initialize detectors early so they're available when features init - initDetectors(args.bundledConfig); + try { + initDetectors(args.bundledConfig); + } catch (error) { + console.error('[detectors] Initialization failed:', error); + // TODO: Consider firing error pixel if needed + } // prettier-ignore const featuresToLoad = isGloballyDisabled(args) diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md index c99d7734b9..b4745fff0e 100644 --- a/injected/src/detectors/README.md +++ b/injected/src/detectors/README.md @@ -22,7 +22,7 @@ sequenceDiagram - `registerDetector(detectorId, { getData, shouldRun?, refresh?, teardown? })` - `getDetectorData(detectorId, { maxAgeMs }?)` -- `getDetectorBatch(detectorIds, options?)` +- `getDetectorsData(detectorIds, options?)` Detectors return arbitrary JSON payloads. Include timestamps if consumers rely on freshness. @@ -35,9 +35,7 @@ detectors/ ├── default-config.js # default detector settings ├── detections/ │ ├── bot-detection.js # CAPTCHA/bot detection -│ ├── fraud-detection.js # anti-fraud/phishing warnings -│ ├── youtube-ads-detection.js # YouTube ad detection -│ └── detection-base.js # optional base for observer-style detectors +│ └── fraud-detection.js # anti-fraud/phishing warnings └── utils/ └── detection-utils.js # DOM helpers (selectors, text matching, visibility, domain matching) ``` @@ -153,10 +151,10 @@ Example: Features can directly import and use the detector service: ```javascript -import { getDetectorBatch } from '../detectors/detector-service.js'; +import { getDetectorsData } from '../detectors/detector-service.js'; // In breakage reporting feature - gates bypassed automatically for manual calls -const detectorData = await getDetectorBatch(['botDetection', 'fraudDetection']); +const detectorData = await getDetectorsData(['botDetection', 'fraudDetection']); // Returns: { botDetection: {...}, fraudDetection: {...} } ``` diff --git a/injected/src/detectors/default-config.js b/injected/src/detectors/default-config.js index 8bae5a8a9c..e569ce19e7 100644 --- a/injected/src/detectors/default-config.js +++ b/injected/src/detectors/default-config.js @@ -50,9 +50,4 @@ export const DEFAULT_DETECTOR_SETTINGS = Object.freeze({ textSources: ['innerText'], }, }, - youtubeAds: { - rootSelector: '#movie_player', - selectors: ['.ytp-ad-text', '.ytp-ad-skip-button', '.ytp-ad-preview-text'], - adClasses: ['ad-showing', 'ad-interrupting'], - }, }); diff --git a/injected/src/detectors/detections/detection-base.js b/injected/src/detectors/detections/detection-base.js deleted file mode 100644 index ee1f28c16f..0000000000 --- a/injected/src/detectors/detections/detection-base.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * PROTOTYPE: Base class for complex detections with continuous monitoring - * TODO: Add mutation observer, re-rooting, callback timers, debouncing - */ -export class DetectionBase { - /** - * @param {object} config - * @param {(result: any) => void=} onInterferenceChange - */ - constructor(config, onInterferenceChange = null) { - this.config = config; - this.onInterferenceChange = onInterferenceChange; - this.isRunning = false; - this.root = null; - this.pollTimer = null; - this.retryTimer = null; - - if (this.onInterferenceChange) { - this.start(); - } - } - - start() { - if (this.isRunning) { - return; - } - this.isRunning = true; - - this.root = this.findRoot(); - if (!this.root) { - this.retryTimer = setTimeout(() => this.start(), 500); - return; - } - - if (this.config.pollInterval) { - this.pollTimer = setInterval(() => this.checkForInterference(), this.config.pollInterval); - } - - this.checkForInterference(); - } - - stop() { - if (!this.isRunning) { - return; - } - this.isRunning = false; - - if (this.pollTimer) { - clearInterval(this.pollTimer); - this.pollTimer = null; - } - - if (this.retryTimer) { - clearTimeout(this.retryTimer); - this.retryTimer = null; - } - } - - detect() { - throw new Error('detect() must be implemented by subclass'); - } - - /** - * @returns {Element|null} - */ - findRoot() { - return document.body; - } - - checkForInterference() {} -} diff --git a/injected/src/detectors/detections/youtube-ads-detection.js b/injected/src/detectors/detections/youtube-ads-detection.js deleted file mode 100644 index 7c8a6f89f9..0000000000 --- a/injected/src/detectors/detections/youtube-ads-detection.js +++ /dev/null @@ -1,62 +0,0 @@ -import { isVisible, queryAllSelectors } from '../utils/detection-utils.js'; -const DEFAULT_CONFIG = { - rootSelector: '#movie_player', - adClasses: ['ad-showing', 'ad-interrupting'], - selectors: ['.ytp-ad-text', '.ytp-ad-skip-button', '.ytp-ad-preview-text'], -}; - -export function createYouTubeAdsDetector(config = {}) { - const mergedConfig = { ...DEFAULT_CONFIG, ...config }; - return { - /** - * Optional gate function - return false to skip detection entirely - * This runs before getData() and can be used for lightweight precondition checks - */ - shouldRun() { - // Only run if the YouTube player root element exists - // This avoids unnecessary DOM scanning on non-video pages - return document.querySelector(mergedConfig.rootSelector) !== null; - }, - - async getData() { - return runYouTubeAdsDetection(mergedConfig); - }, - }; -} - -export function runYouTubeAdsDetection(config = DEFAULT_CONFIG) { - const root = document.querySelector(config.rootSelector); - if (!root) { - return emptyResult(); - } - - const hasAdClass = config.adClasses.some((cls) => root.classList.contains(cls)); - const adElements = queryAllSelectors(config.selectors, root); - const hasVisibleAdElement = adElements.some((el) => isVisible(el)); - - const detected = hasAdClass || hasVisibleAdElement; - - return detected - ? { - detected: true, - type: 'youtubeAds', - results: [ - { - adCurrentlyPlaying: true, - adType: 'video-ad', - source: 'snapshot', - }, - ], - timestamp: Date.now(), - } - : emptyResult(); -} - -function emptyResult() { - return { - detected: false, - type: 'youtubeAds', - results: [], - timestamp: Date.now(), - }; -} diff --git a/injected/src/detectors/detector-init.js b/injected/src/detectors/detector-init.js index 73b4897f92..709673e12b 100644 --- a/injected/src/detectors/detector-init.js +++ b/injected/src/detectors/detector-init.js @@ -9,7 +9,6 @@ import { registerDetector } from './detector-service.js'; import { DEFAULT_DETECTOR_SETTINGS } from './default-config.js'; import { createBotDetector } from './detections/bot-detection.js'; import { createFraudDetector } from './detections/fraud-detection.js'; -import { createYouTubeAdsDetector } from './detections/youtube-ads-detection.js'; import { matchesDomainPatterns } from './utils/detection-utils.js'; /** @@ -114,7 +113,6 @@ export function initDetectors(bundledConfig) { // Register each detector if its settings exist registerIfEnabled('botDetection', detectorSettings.botDetection, createBotDetector); registerIfEnabled('fraudDetection', detectorSettings.fraudDetection, createFraudDetector); - registerIfEnabled('youtubeAds', detectorSettings.youtubeAds, createYouTubeAdsDetector); // Auto-run detectors after a short delay to let page settle if (autoRunDetectors.length > 0) { @@ -123,10 +121,10 @@ export function initDetectors(bundledConfig) { // Use setTimeout to avoid blocking page load setTimeout(async () => { - const { getDetectorBatch } = await import('./detector-service.js'); + const { getDetectorsData } = await import('./detector-service.js'); // Run all auto-run detectors with _autoRun flag (gates will be checked) - await getDetectorBatch(autoRunDetectors, { _autoRun: true }); + await getDetectorsData(autoRunDetectors, { _autoRun: true }); console.log('[detectors] Auto-run complete for:', autoRunDetectors); }, autoRunDelayMs); diff --git a/injected/src/detectors/detector-service.js b/injected/src/detectors/detector-service.js index a551e98ec6..19ce471666 100644 --- a/injected/src/detectors/detector-service.js +++ b/injected/src/detectors/detector-service.js @@ -39,7 +39,9 @@ export function registerDetector(detectorId, registration) { */ export async function getDetectorData(detectorId, options = {}) { const { maxAgeMs } = options; - const cached = /** @type {CachedSnapshot | undefined} */ (cache.get(detectorId)); + // Include URL in cache key to handle SPA navigation (e.g., YouTube) + const cacheKey = `${detectorId}:${location.href}`; + const cached = /** @type {CachedSnapshot | undefined} */ (cache.get(cacheKey)); if (cached) { const age = Date.now() - cached.ts; @@ -57,7 +59,7 @@ export async function getDetectorData(detectorId, options = {}) { try { // Pass options to the runner so gates can check _autoRun flag const data = await runner(options); - cache.set(detectorId, { data, ts: Date.now() }); + cache.set(cacheKey, { data, ts: Date.now() }); return data; } catch (error) { console.error(`[detectorService] Failed to fetch data for ${detectorId}`, error); @@ -73,7 +75,7 @@ export async function getDetectorData(detectorId, options = {}) { * @param {boolean} [options._autoRun] - Internal flag indicating auto-run (gates apply) * @returns {Promise>} Object mapping detector IDs to their data */ -export async function getDetectorBatch(detectorIds, options = {}) { +export async function getDetectorsData(detectorIds, options = {}) { const results = {}; for (const detectorId of detectorIds) { results[detectorId] = await getDetectorData(detectorId, options); diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index 40ebf13c3a..e350185bd3 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -1,6 +1,6 @@ import ContentFeature from '../content-feature'; import { getExpandedPerformanceMetrics, getJsPerformanceMetrics } from './breakage-reporting/utils.js'; -import { getDetectorBatch } from '../detectors/detector-service.js'; +import { getDetectorsData } from '../detectors/detector-service.js'; export default class BreakageReporting extends ContentFeature { init() { @@ -10,10 +10,9 @@ export default class BreakageReporting extends ContentFeature { const referrer = document.referrer; // Collect detector data (gates bypassed by default for manual calls) - const detectorData = await getDetectorBatch([ + const detectorData = await getDetectorsData([ 'botDetection', - 'fraudDetection', - 'youtubeAds' + 'fraudDetection' ]); const result = { From 443628af721d0ef583b43f269e103575c45d8d4d Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Wed, 12 Nov 2025 08:44:15 -0700 Subject: [PATCH 12/13] fix type erros --- injected/src/detectors/detections/bot-detection.js | 2 +- injected/src/detectors/detections/fraud-detection.js | 2 +- injected/src/detectors/detector-init.js | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/injected/src/detectors/detections/bot-detection.js b/injected/src/detectors/detections/bot-detection.js index ca57510df8..9eeee4ada5 100644 --- a/injected/src/detectors/detections/bot-detection.js +++ b/injected/src/detectors/detections/bot-detection.js @@ -6,7 +6,7 @@ import { checkSelectors, checkWindowProperties, matchesSelectors, matchesTextPat */ export function createBotDetector(config = {}) { return { - async getData() { + getData() { return runBotDetection(config); }, }; diff --git a/injected/src/detectors/detections/fraud-detection.js b/injected/src/detectors/detections/fraud-detection.js index 0754bf0a38..92dbe74b3e 100644 --- a/injected/src/detectors/detections/fraud-detection.js +++ b/injected/src/detectors/detections/fraud-detection.js @@ -2,7 +2,7 @@ import { checkSelectorsWithVisibility, checkTextPatterns } from '../utils/detect export function createFraudDetector(config = {}) { return { - async getData() { + getData() { return runFraudDetection(config); }, }; diff --git a/injected/src/detectors/detector-init.js b/injected/src/detectors/detector-init.js index 709673e12b..9d83955b29 100644 --- a/injected/src/detectors/detector-init.js +++ b/injected/src/detectors/detector-init.js @@ -63,13 +63,13 @@ function createGatedDetector(registration, domains) { } // All gates passed, run the detector - return getData(); + return await getData(options); }, refresh: refresh ? async (options) => { if (!checkGates(options, domains, shouldRun)) { return null; } - return refresh(); + return await refresh(options); } : undefined, teardown, }; From 13eea370eebdab349bcc69481a939342c17f8bf6 Mon Sep 17 00:00:00 2001 From: jdorweiler Date: Wed, 12 Nov 2025 13:46:19 -0700 Subject: [PATCH 13/13] remove unused' --- injected/src/error-utils.js | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 injected/src/error-utils.js diff --git a/injected/src/error-utils.js b/injected/src/error-utils.js deleted file mode 100644 index 4ee3d300b5..0000000000 --- a/injected/src/error-utils.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @template T - * @param {function(): T} fn - The function to call safely - * @param {object} [options] - * @param {string} [options.errorMessage] - The error message to log - * @returns {T|null} - The result of the function call, or null if an error occurred - */ -export function safeCall(fn, { errorMessage } = {}) { - try { - return fn(); - } catch (e) { - console.error(errorMessage ?? '[safeCall] Error:', e); - // TODO fire pixel - return null; - } -}