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(),
+ };
+}