-
Notifications
You must be signed in to change notification settings - Fork 31
[PROTOTYPE] web interference detection #2048
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() }); | ||
| } | ||
| }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| # Web Interference Detection Service | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm wondering instead of minting a whole new services/ directory just having these as injected/
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We'd be creating an artificial dependency between 2 features. TBH I don't know if we already have stuff like this in the code already, but theoretically, I don't think features should depend on each other. Happy to defer to you since you know this arch better. |
||
| 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<br/>ContentFeature] | ||
| Service[WebInterferenceDetectionService] | ||
| subgraph Detectors["Detection Classes"] | ||
| Bot[BotDetection] | ||
| Fraud[FraudDetection] | ||
| YouTube[YouTubeAdsDetection] | ||
| end | ||
| Base[DetectionBase] | ||
| Utils[detection-utils] | ||
| end | ||
| NativeApps <-->|"messaging<br/>(detectInterference,<br/>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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }, | ||
| }, | ||
| }), | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jdorweiler
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This runs detection synchronously? The way I did this in my version uses
autoRun: trueand caching with TTL. If you always need a fresh detection I could add anoCacheoption. So your use for this would look something like