diff --git a/README.md b/README.md index 1dd8a30..e6af5c3 100644 --- a/README.md +++ b/README.md @@ -22,51 +22,75 @@ ## Features ### 🎯 Core Functionality + - **YouTube Ad Blocking**: Blocks video ads, overlays, banners, sponsored content, and skippable/non-skippable pre-rolls +- **YouTube Hardening Pipeline**: Combines document-start injection, main-world payload sanitization, player-surface suppression, and runtime session rules - **Universal Blocking**: Works on all websites including news sites, social media, and sites with Google AdSense - **500+ Ad Domains**: Comprehensive blocklist covering all major ad networks - **DeclarativeNetRequest**: Uses Manifest V3's efficient network blocking API ### 🚀 Performance + - **< 50ms Overhead**: Optimized for minimal performance impact - **Debounced Observers**: Smart MutationObserver implementation prevents CPU spikes - **Memory Efficient**: Uses WeakSet for element tracking to prevent memory leaks +### 🤖 AI-Powered Ad Detection + +- **LLM API Integration**: Connect your own API key from OpenAI, Anthropic, OpenRouter, Groq, or any OpenAI-compatible endpoint +- **Intelligent Classification**: LLM analyzes page element metadata to detect ads that evade traditional pattern-matching +- **Searchable Model Picker**: Browse and search hundreds of models with live pricing from OpenRouter +- **Response Caching**: LRU cache with domain-level pattern learning reduces API calls +- **Smooth Removal**: Detected ads fade out and collapse seamlessly, including parent containers +- **Video Ad Interception**: Main-world player patching neutralizes pre-roll ad queues on streaming/movie sites before they play +- **Privacy-First**: Only element metadata (tag names, classes, dimensions) is sent to your chosen provider — no personal data, cookies, or history + ### 🔒 Privacy + - **No Data Collection**: All processing happens locally - **No Telemetry**: No analytics, tracking pixels, or user profiling - **Local-First Operation**: Blocking and detection run on-device (optional rule updates can be fetched when auto-update is enabled) +- **Local Diagnostics Export**: YouTube diagnostics can be exported manually for debugging without sending data to a server - **On-Device ML**: Optional TensorFlow.js-based ad detection runs entirely in your browser +- **Your API Key, Your Control**: AI detection keys are stored locally and never leave your device ### 📊 Features Overview -| Feature | Description | -|---------|-------------| -| Video Ad Blocking | Skips YouTube video ads automatically | -| Banner Ad Blocking | Removes banner ads across all sites | -| Popup Blocking | Prevents popup and overlay ads | -| Anti-Adblock Bypass | Defeats adblock detection scripts | -| Custom Rules | Add your own CSS selectors per site | -| Whitelist | Disable blocking on specific sites | -| Statistics | Track blocked ads and saved time | -| Dark Mode | Full dark theme support | -| Import/Export | Backup and restore settings | +| Feature | Description | +| ---------------------- | ------------------------------------------------------ | +| Video Ad Blocking | Skips YouTube video ads automatically | +| YouTube Session Rules | Runtime DNR session rules harden first-party YouTube ad suppression | +| Payload Sanitization | MAIN-world sanitization strips ad payloads before UI hydration | +| Banner Ad Blocking | Removes banner ads across all sites | +| Popup Blocking | Prevents popup and overlay ads | +| Anti-Adblock Bypass | Defeats adblock detection scripts | +| AI Ad Detection | LLM-powered intelligent ad classification | +| Video Pre-roll Bypass | Intercepts and skips movie/streaming pre-roll ad queues | +| Diagnostics Export | Export local YouTube diagnostics snapshots for missed-ad triage | +| Custom Rules | Add your own CSS selectors per site | +| Whitelist | Disable blocking on specific sites | +| Statistics | Track blocked ads and saved time | +| Dark Mode | Full dark theme support | +| Import/Export | Backup and restore settings | --- ## Installation ### Chrome Web Store (Recommended) -*Coming soon* + +_Coming soon_ ### Firefox Add-ons -*Coming soon* + +_Coming soon_ ### Manual Installation #### Chrome / Chromium-based Browsers 1. Download or clone this repository: + ```bash git clone https://github.com/Edmon02/adeclipse.git cd adeclipse @@ -130,15 +154,22 @@ AdEclipse/ ├── src/ │ ├── background/ │ │ ├── background.js # Service worker +│ │ ├── youtube-diagnostics.js # Local YouTube diagnostics manager +│ │ ├── youtube-session-rules.js # Runtime YouTube session rule builder │ │ ├── storage.js # Settings management │ │ ├── stats.js # Statistics tracking │ │ └── rules.js # Dynamic rules │ ├── content/ │ │ ├── youtube.js # YouTube-specific blocking +│ │ ├── youtube-mainworld.js # MAIN-world YouTube payload sanitizer │ │ ├── youtube.css # YouTube ad hiding styles │ │ ├── general.js # General site blocking │ │ ├── general.css # General ad hiding styles -│ │ └── anti-adblock.js # Adblock detection bypass +│ │ ├── anti-adblock.js # Adblock detection bypass +│ │ ├── ai-scanner.js # AI element scanner +│ │ ├── ai-scanner.css # AI removal animations +│ │ ├── video-ad-interceptor.js # Video pre-roll interceptor +│ │ └── player-mainworld-patch.js # Main-world player ad neutralizer │ ├── popup/ │ │ ├── popup.html # Popup interface │ │ ├── popup.css # Popup styles @@ -149,7 +180,11 @@ AdEclipse/ │ │ └── options.js # Settings logic │ └── ml/ │ ├── detector.js # ML-based ad detection -│ └── features.js # Feature extraction +│ ├── features.js # Feature extraction +│ ├── ai-provider.js # Multi-provider LLM client +│ ├── ai-detector.js # AI detection orchestrator +│ ├── ai-cache.js # LRU verdict cache +│ └── prompt-templates.js # LLM prompt engineering ├── icons/ │ └── *.png # Extension icons └── tests/ @@ -175,19 +210,22 @@ npm run test:watch # Run specific test file npm test -- --testPathPattern=youtube + +# Run the YouTube hardening regression slice +npx jest tests/unit/youtube.test.js tests/unit/youtube-diagnostics.test.js tests/unit/youtube-session-rules.test.js tests/unit/storage.test.js --runInBand ``` ### Test Structure ```javascript // Example test for ad detection -describe('YouTubeAdBlocker', () => { - test('should detect video ads', () => { - const adElement = createMockElement('.ytp-ad-module'); +describe("YouTubeAdBlocker", () => { + test("should detect video ads", () => { + const adElement = createMockElement(".ytp-ad-module"); expect(isVideoAd(adElement)).toBe(true); }); - test('should skip detected ads', async () => { + test("should skip detected ads", async () => { const video = createMockVideo({ ad: true }); await skipAd(video); expect(video.currentTime).toBe(video.duration); @@ -237,28 +275,42 @@ Create a new content script or add selectors to `rules/site-selectors.json`: ### Blocking Modes -| Mode | Description | -|------|-------------| -| Balanced | Recommended default with performance optimization | -| Aggressive | Maximum blocking, may affect some content | -| Light | Essential ads only, less intrusive | +| Mode | Description | +| ---------- | ------------------------------------------------- | +| Balanced | Recommended default with performance optimization | +| Aggressive | Maximum blocking, may affect some content | +| Light | Essential ads only, less intrusive | ### YouTube Settings -| Setting | Default | Description | -|---------|---------|-------------| -| Auto-skip | On | Automatically skip skippable ads | -| Skip/End Ad Handling | On | Seeks ad playback to end and triggers skip when available | -| Mute | On | Mute ads during playback | -| Overlay Cleanup | On | Removes YouTube ad overlays and promoted UI elements | +| Setting | Default | Description | +| -------------------- | ------- | --------------------------------------------------------- | +| Auto-skip | On | Automatically skip skippable ads | +| Skip/End Ad Handling | On | Seeks ad playback to end and triggers skip when available | +| Mute | On | Mute ads during playback | +| Document-start inject| On | Registers YouTube scripts as early as possible before hydration | +| Overlay Cleanup | On | Removes YouTube ad overlays and promoted UI elements | +| Diagnostics export | On in Debug | Captures local YouTube diagnostics snapshots for troubleshooting | + +### AI Detection Settings + +| Setting | Default | Description | +| -------------------- | ------- | ------------------------------------------------ | +| AI Provider | OpenAI | OpenAI, Anthropic, OpenRouter, Groq, or Custom | +| Scan Mode | Smart | Smart (heuristic + AI), AI-only, or AI-assist | +| Confidence Threshold | 70% | Minimum confidence to remove detected elements | +| Continuous Scan | On | Re-scan on dynamic content changes | +| Smooth Removal | On | Animated fade-out and collapse | +| Cache Duration | 24h | How long AI verdicts are cached | ### Performance Settings -| Setting | Default | Description | -|---------|---------|-------------| -| Observer debounce | 100ms | MutationObserver throttling | -| Performance mode | Off | Reduces observation frequency | -| ML detection | Off | Enable TensorFlow.js detection | +| Setting | Default | Description | +| ----------------- | ------- | ------------------------------ | +| Lazy load | On | Defers non-essential scripts | +| Cache blocked UI | On | Reuses known blocked elements | +| Observer debounce | 100ms | MutationObserver throttling | +| ML detection | Off | Enable TensorFlow.js detection | --- @@ -268,22 +320,41 @@ Create a new content script or add selectors to `rules/site-selectors.json`: ```javascript // Get current settings -const settings = await chrome.runtime.sendMessage({ type: 'GET_SETTINGS' }); +const settings = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" }); // Update settings await chrome.runtime.sendMessage({ - type: 'UPDATE_SETTINGS', - data: { enabled: true, mode: 'balanced' } + type: "UPDATE_SETTINGS", + data: { enabled: true, mode: "balanced" }, }); // Get statistics -const stats = await chrome.runtime.sendMessage({ type: 'GET_STATS' }); +const stats = await chrome.runtime.sendMessage({ type: "GET_STATS" }); console.log(`Blocked today: ${stats.today.adsBlocked}`); // Record blocked ad await chrome.runtime.sendMessage({ - type: 'INCREMENT_BLOCKED', - data: { type: 'videoAd', domain: 'youtube.com' } + type: "INCREMENT_BLOCKED", + data: { type: "videoAd", domain: "youtube.com" }, +}); +``` + +### AI Detection Messages + +```javascript +// Scan elements for ads using AI +const results = await chrome.runtime.sendMessage({ + type: "AI_SCAN_ELEMENTS", + data: { elements: [{ tag: "div", classes: "ad-banner", ... }] }, +}); + +// Test AI connection +const test = await chrome.runtime.sendMessage({ type: "AI_TEST_CONNECTION" }); + +// Fetch available models for a provider +const { models } = await chrome.runtime.sendMessage({ + type: "AI_FETCH_MODELS", + data: { provider: "openrouter", apiKey: "sk-..." }, }); ``` @@ -292,13 +363,18 @@ await chrome.runtime.sendMessage({ ```javascript // Get site state for current page const siteState = await chrome.runtime.sendMessage({ - type: 'GET_SITE_ENABLED' + type: "GET_SITE_ENABLED", }); // Request selectors for hostname const selectors = await chrome.runtime.sendMessage({ - type: 'GET_SELECTORS', - data: { hostname: window.location.hostname } + type: "GET_SELECTORS", + data: { hostname: window.location.hostname }, +}); + +// Export local YouTube diagnostics +const diagnostics = await chrome.runtime.sendMessage({ + type: "EXPORT_YOUTUBE_DIAGNOSTICS", }); ``` @@ -311,7 +387,8 @@ const selectors = await chrome.runtime.sendMessage({ 1. Make sure AdEclipse is enabled (check the popup) 2. Check if the site is whitelisted 3. Try switching to "Aggressive" mode -4. Add custom selectors for the specific ads +4. Enable Debug Mode and export YouTube diagnostics from Options → Advanced +5. Add custom selectors for the specific ads ### Performance Issues? @@ -324,20 +401,21 @@ const selectors = await chrome.runtime.sendMessage({ 1. Check for error messages in the browser console 2. Try reloading the extension 3. Clear browser cache and reload the page -4. Check for conflicts with other extensions +4. Re-open the Options page to confirm rules loaded successfully +5. Check for conflicts with other extensions --- ## Browser Compatibility -| Browser | Version | Status | -|---------|---------|--------| -| Chrome | 111+ | ✅ Full support | -| Edge | 111+ | ✅ Full support | -| Firefox | 109+ | ✅ Full support | -| Brave | Latest | ✅ Full support | -| Opera | 97+ | ✅ Full support | -| Safari | - | ❌ Not supported | +| Browser | Version | Status | +| ------- | ------- | ---------------- | +| Chrome | 111+ | ✅ Full support | +| Edge | 111+ | ✅ Full support | +| Firefox | 109+ | ✅ Full support | +| Brave | Latest | ✅ Full support | +| Opera | 97+ | ✅ Full support | +| Safari | - | ❌ Not supported | --- @@ -382,7 +460,18 @@ MIT License - see [LICENSE](LICENSE) for details. ## Changelog +### v1.1.0 + +- **AI-Powered Ad Detection** — connect OpenAI, Anthropic, OpenRouter, Groq, or any OpenAI-compatible LLM to classify and remove ads intelligently +- **Searchable Model Picker** — browse hundreds of models with live pricing from OpenRouter +- **Video Pre-roll Bypass** — main-world player patching intercepts ad queues on streaming/movie sites (JWPlayer, Video.js, Hls.js, Shaka) +- **Empty Space Collapse** — parent containers are recursively collapsed after ad removal, eliminating blank gaps +- **Masked API Key Preview** — saved keys display securely in the settings UI +- **Improved Settings Persistence** — robust rehydration of AI settings on page load with auto-save on input +- **62 Unit Tests** — comprehensive coverage for AI provider, cache, detector, and scanner modules + ### v1.0.0 (Initial Release) + - YouTube video ad blocking - General website ad blocking - Anti-adblock bypass @@ -400,6 +489,7 @@ MIT License - see [LICENSE](LICENSE) for details. - Manifest V3 migration guidance from Google Chrome team - Community filter lists for domain references - TensorFlow.js for ML capabilities +- OpenRouter for multi-model API access and pricing data --- diff --git a/babel.config.json b/babel.config.json new file mode 100644 index 0000000..041c89c --- /dev/null +++ b/babel.config.json @@ -0,0 +1,9 @@ +{ + "env": { + "test": { + "presets": [ + ["@babel/preset-env", { "targets": { "node": "current" } }] + ] + } + } +} diff --git a/manifest.json b/manifest.json index 82ebb72..2e4907e 100644 --- a/manifest.json +++ b/manifest.json @@ -75,7 +75,8 @@ "resources": [ "rules/*.json", "src/content/*.js", - "src/content/*.css" + "src/content/*.css", + "src/ml/*.js" ], "matches": [""] } diff --git a/project_plan.prompt.md b/project_plan.prompt.md index e69de29..8043dad 100644 --- a/project_plan.prompt.md +++ b/project_plan.prompt.md @@ -0,0 +1,28 @@ +Build a complete, fully functional browser extension called 'AdEclipse' using Manifest V3 for Chrome and Firefox compatibility. The extension should primarily block all types of ads on YouTube (including video ads, overlay ads, banner ads, sponsored content, and skippable/non-skippable pre-rolls) by intercepting requests, mutating DOM elements, and using content scripts. Then, extend it to block ads on other websites, such as article-based sites like news portals (e.g., CNN, Reddit, or any site with embedded Google AdSense, banners, pop-ups, or interstitials). + +Key requirements for sophistication and best user experience: +- **Core Functionality**: + - Use declarativeNetRequest to block ad-related domains and URLs (e.g., googlevideo.com for YouTube ads, doubleclick.net, adservice.google.com for general ads). Include a comprehensive, updatable list of over 500 common ad domains, categorized by site type (YouTube-specific, general web). + - Inject content scripts to dynamically detect and remove ad elements via CSS selectors, XPath, or mutation observers (e.g., hide '.ytp-ad-module' on YouTube, or '.ad-container' on generic sites). Use AI-like heuristics (simple regex or optional TensorFlow.js for pattern recognition) to identify and block evolving ad formats without hardcoding. + - Handle YouTube specifics: Skip ads automatically if possible (simulate skip button clicks), mute during ads, or fast-forward. For other sites: Remove inline ads in articles, sidebars, pop-unders, and auto-playing videos. + - Extensibility: Allow easy addition of new site rules via a JSON config file (e.g., users can add custom domains or selectors for sites like Twitter/X or blogs). + +- **Advanced Features**: + - **Machine Learning Integration**: Optionally integrate TensorFlow.js (load via CDN) for on-device ad classification – train a simple model (provide sample code) to detect ad images/text based on keywords, sizes, or positions, falling back to rule-based if disabled for performance. + - **Performance Optimization**: Use lazy loading for scripts, debounce mutation observers to avoid CPU spikes, and cache blocked elements. Ensure the extension doesn't slow down page loads (target <50ms overhead). + - **User Customization**: Include a popup UI with toggles for enabling/disabling per-site (whitelist/blacklist), ad types (e.g., video vs. banner), and modes (aggressive vs. light blocking). Add a stats dashboard showing blocked ads count, data saved, and time saved (e.g., 'Skipped 5 ads today, saving 2 minutes'). + - **Privacy and Security**: No data collection; all processing local. Handle edge cases like ad blockers detectors (anti-anti-adblock) by spoofing requests or injecting stealth scripts. + - **UI/UX Polish**: Use modern web tech (HTML/CSS/JS with Tailwind CSS or Bootstrap for styling). Popup should be responsive, dark-mode compatible, with animations (e.g., fade-out for removed ads). Options page for advanced settings, import/export rules, and auto-updates from a GitHub repo (fetch JSON rules periodically). + - **Error Handling and Logging**: Robust try-catch, console logging only in debug mode, and a report bug feature that sends anonymized logs to a placeholder email. + - **Testing and Compatibility**: Provide unit tests (using Jest) for key functions like ad detection. Ensure it works on mobile browsers if possible. Handle updates to YouTube/web APIs by making selectors configurable. + +Structure the project as a ZIP-ready folder with: +- manifest.json (V3 compliant, with permissions: declarativeNetRequest, storage, tabs, webRequest, etc.). +- background.js for request blocking and event handling. +- content.js for DOM manipulation. +- popup.html/js/css for the UI. +- options.html/js for settings. +- rules.json for ad domains/selectors (initially populated with YouTube and 10+ common sites like nytimes.com, forbes.com). +- Include a README.md with installation instructions, how to build/test, and how to extend for new sites. + +Output the full code for all files, zipped structure description, and any setup commands (e.g., for loading in Chrome). Make it sophisticated, production-ready, and focused on delivering the smoothest ad-free experience without breaking site functionality. \ No newline at end of file diff --git a/rules/ad-domains.json b/rules/ad-domains.json index aa81a7d..16078ec 100644 --- a/rules/ad-domains.json +++ b/rules/ad-domains.json @@ -30,7 +30,77 @@ "*://*.googlevideo.com/*oad=*", "*://*.youtube.com/get_midroll_*", "*://www.youtube.com/youtubei/v1/player/ad_break*" - ] + ], + "sessionRuleGroups": { + "telemetry": [ + { + "urlFilter": "||youtube.com/api/stats/ads", + "resourceTypes": ["xmlhttprequest", "ping"], + "priority": 6, + "settingGate": "trackers" + }, + { + "urlFilter": "||youtube.com/ptracking", + "resourceTypes": ["xmlhttprequest", "ping", "image"], + "priority": 6, + "settingGate": "trackers" + }, + { + "urlFilter": "||youtube.com/pcs/activeview", + "resourceTypes": ["xmlhttprequest", "ping", "image"], + "priority": 6, + "settingGate": "trackers" + }, + { + "urlFilter": "||youtube.com/api/stats/qoe", + "resourceTypes": ["xmlhttprequest"], + "priority": 5, + "settingGate": "trackers" + } + ], + "playerAds": [ + { + "urlFilter": "||youtube.com/pagead/", + "resourceTypes": ["xmlhttprequest", "sub_frame", "script", "other"], + "priority": 7, + "settingGate": "sponsoredContent" + }, + { + "urlFilter": "||youtube.com/get_midroll_", + "resourceTypes": ["xmlhttprequest"], + "priority": 7, + "settingGate": "videoAds" + }, + { + "urlFilter": "||youtube.com/youtubei/v1/player/ad_break", + "resourceTypes": ["xmlhttprequest"], + "priority": 7, + "settingGate": "videoAds" + }, + { + "urlFilter": "||youtube.com/get_video_info", + "resourceTypes": ["xmlhttprequest"], + "priority": 5, + "settingGate": "videoAds" + } + ], + "mediaAds": [ + { + "urlFilter": "||googlevideo.com/videoplayback*ctier=L", + "resourceTypes": ["media", "xmlhttprequest"], + "requestDomains": ["googlevideo.com"], + "priority": 7, + "settingGate": "videoAds" + }, + { + "urlFilter": "||googlevideo.com/videoplayback*oad=", + "resourceTypes": ["media", "xmlhttprequest"], + "requestDomains": ["googlevideo.com"], + "priority": 7, + "settingGate": "videoAds" + } + ] + } }, "googleAds": { diff --git a/src/background/background.js b/src/background/background.js index 98f9d38..b526052 100644 --- a/src/background/background.js +++ b/src/background/background.js @@ -7,15 +7,34 @@ import { StorageManager } from './storage.js'; import { StatsTracker } from './stats.js'; import { RulesManager } from './rules.js'; +import { YouTubeDiagnosticsManager } from './youtube-diagnostics.js'; +import { buildYouTubeSessionRules } from './youtube-session-rules.js'; +import { AIAdDetector } from '../ml/ai-detector.js'; +import { AIProvider } from '../ml/ai-provider.js'; // Initialize managers const storage = new StorageManager(); const stats = new StatsTracker(); const rules = new RulesManager(); +const youtubeDiagnostics = new YouTubeDiagnosticsManager(); + +// AI detector (lazy-loaded when enabled) +let aiDetector = null; +let youtubeRegistrationsActive = false; // Debug mode flag let DEBUG_MODE = false; +const YOUTUBE_CONTENT_SCRIPT_IDS = [ + 'adeclipse-youtube-mainworld', + 'adeclipse-youtube-isolated' +]; + +const YOUTUBE_MATCHES = [ + '*://youtube.com/*', + '*://*.youtube.com/*' +]; + /** * Logger utility that respects debug mode */ @@ -43,6 +62,16 @@ async function initialize() { // Sync declarative rules based on enabled state await syncDeclarativeRules(settings.enabled); + // Register document-start YouTube scripts when supported + await syncContentScriptRegistrations(settings); + + // Keep YouTube diagnostics/session rules in sync with current settings + await youtubeDiagnostics.init(settings); + await syncYouTubeSessionRules(settings); + + // Initialize AI detector if enabled + await initAIDetector(settings); + // Set up alarms for periodic tasks setupAlarms(); @@ -55,6 +84,88 @@ async function initialize() { } } +function buildYouTubeExcludeMatches(settings) { + const whitelist = settings?.whitelist || []; + + return [...new Set( + whitelist + .filter((hostname) => hostname === 'youtube.com' || hostname.endsWith('.youtube.com')) + .map((hostname) => `*://${hostname}/*`) + )]; +} + +function supportsDynamicYouTubeRegistration() { + return Boolean( + chrome.scripting?.registerContentScripts && + chrome.scripting?.unregisterContentScripts + ); +} + +function shouldRegisterYouTubeScripts(settings) { + return Boolean( + supportsDynamicYouTubeRegistration() && + settings?.enabled && + settings.youtube?.enabled !== false && + settings.youtube?.useDocumentStartScripts !== false + ); +} + +function getYouTubeContentScriptRegistrations(settings) { + const excludeMatches = buildYouTubeExcludeMatches(settings); + + return [ + { + id: YOUTUBE_CONTENT_SCRIPT_IDS[0], + matches: YOUTUBE_MATCHES, + excludeMatches, + js: ['src/content/youtube-mainworld.js'], + allFrames: true, + runAt: 'document_start', + world: 'MAIN' + }, + { + id: YOUTUBE_CONTENT_SCRIPT_IDS[1], + matches: YOUTUBE_MATCHES, + excludeMatches, + css: ['src/content/youtube.css'], + js: ['src/content/youtube-utils.js', 'src/content/youtube.js'], + allFrames: true, + runAt: 'document_start', + world: 'ISOLATED' + } + ]; +} + +async function syncContentScriptRegistrations(settings) { + if (!supportsDynamicYouTubeRegistration()) { + youtubeRegistrationsActive = false; + return; + } + + try { + await chrome.scripting.unregisterContentScripts({ ids: YOUTUBE_CONTENT_SCRIPT_IDS }); + } catch (error) { + log.debug('YouTube content script unregister skipped:', error?.message || error); + } + + youtubeRegistrationsActive = false; + + if (!shouldRegisterYouTubeScripts(settings)) { + return; + } + + try { + await chrome.scripting.registerContentScripts( + getYouTubeContentScriptRegistrations(settings) + ); + youtubeRegistrationsActive = true; + log.debug('Registered document-start YouTube content scripts'); + } catch (error) { + youtubeRegistrationsActive = false; + log.warn('Falling back to tab-update YouTube injection:', error?.message || error); + } +} + /** * Set up periodic alarms */ @@ -66,6 +177,51 @@ function setupAlarms() { chrome.alarms.create('syncStats', { periodInMinutes: 5 }); } +/** + * Initialize AI ad detector + */ +async function initAIDetector(settings) { + if (!settings?.ai?.enabled || !settings.ai.apiKey) { + aiDetector = null; + return; + } + + try { + aiDetector = new AIAdDetector(); + await aiDetector.init(settings); + log.info('AI detector initialized'); + } catch (error) { + log.error('AI detector init failed:', error); + aiDetector = null; + } +} + +async function syncYouTubeSessionRules(settings) { + if (!chrome.declarativeNetRequest?.updateSessionRules) { + return; + } + + try { + const existingRules = await chrome.declarativeNetRequest.getSessionRules(); + const existingRuleIds = existingRules + .map((rule) => rule.id) + .filter((ruleId) => ruleId >= 20000 && ruleId < 21000); + + const nextRules = buildYouTubeSessionRules(settings, { + sessionRuleGroups: rules.getYouTubeSessionRuleConfig() + }); + + await chrome.declarativeNetRequest.updateSessionRules({ + removeRuleIds: existingRuleIds, + addRules: nextRules + }); + + log.debug('YouTube session rules synced:', nextRules.length); + } catch (error) { + log.error('Failed to sync YouTube session rules:', error); + } +} + // Listen for alarms chrome.alarms.onAlarm.addListener(async (alarm) => { log.debug('Alarm triggered:', alarm.name); @@ -73,6 +229,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => { switch (alarm.name) { case 'updateRules': await rules.checkForUpdates(); + await syncYouTubeSessionRules(await storage.getSettings()); break; case 'syncStats': await stats.sync(); @@ -110,6 +267,10 @@ async function handleMessage(message, sender) { await storage.updateSettings(data); const updatedSettings = await storage.getSettings(); await syncDeclarativeRules(updatedSettings.enabled); + await syncContentScriptRegistrations(updatedSettings); + youtubeDiagnostics.configure(updatedSettings); + await syncYouTubeSessionRules(updatedSettings); + await initAIDetector(updatedSettings); await updateBadge(); return { success: true }; @@ -155,6 +316,23 @@ async function handleMessage(message, sender) { case 'REPORT_BUG': return await handleBugReport(data); + + case 'YOUTUBE_DIAGNOSTIC_EVENT': + return youtubeDiagnostics.record({ + ...data, + url: data?.url || sender.url || sender.tab?.url, + tabId: sender.tab?.id + }); + + case 'GET_YOUTUBE_DIAGNOSTICS': + return youtubeDiagnostics.getSnapshot(data?.limit || 50); + + case 'EXPORT_YOUTUBE_DIAGNOSTICS': + return await youtubeDiagnostics.exportSnapshot(data?.limit); + + case 'CLEAR_YOUTUBE_DIAGNOSTICS': + await youtubeDiagnostics.clear(); + return { success: true }; case 'TOGGLE_SITE': return await toggleSiteBlocking(data.hostname, data.enabled); @@ -176,6 +354,57 @@ async function handleMessage(message, sender) { case 'SAVE_CUSTOM_RULES': await storage.saveCustomRules(data); await rules.reloadRules(); + await syncYouTubeSessionRules(await storage.getSettings()); + return { success: true }; + + // AI Detection handlers + case 'AI_SCAN_ELEMENTS': + return await handleAIScan(data); + + case 'AI_GET_CONFIG': { + const aiSettings = await storage.getSettings(); + const ai = aiSettings.ai || {}; + return { + enabled: ai.enabled && !!ai.apiKey, + scanMode: ai.scanMode || 'smart', + smoothRemoval: ai.smoothRemoval !== false, + debugMode: aiSettings.debugMode || false, + confidenceThreshold: ai.confidenceThreshold ?? 0.7, + scanOnLoad: ai.scanOnLoad !== false, + continuousScan: ai.continuousScan !== false + }; + } + + case 'AI_TEST_CONNECTION': { + try { + const testProvider = new AIProvider(); + testProvider.configure(data); + return await testProvider.testConnection(); + } catch (error) { + return { success: false, error: error.message }; + } + } + + case 'AI_GET_PROVIDERS': + return { providers: AIProvider.getProviders() }; + + case 'AI_FETCH_MODELS': { + try { + const models = await AIProvider.fetchRemoteModels(data.provider, data.apiKey); + return { models }; + } catch (error) { + return { models: [], error: error.message }; + } + } + + case 'AI_GET_USAGE': + return { + usage: aiDetector ? aiDetector.getUsageStats() : { totalTokens: 0, totalRequests: 0 }, + cache: aiDetector ? aiDetector.getCacheStats() : { memoryCacheSize: 0, patternCacheSize: 0 } + }; + + case 'AI_CLEAR_CACHE': + if (aiDetector) aiDetector.clearCache(); return { success: true }; default: @@ -184,6 +413,29 @@ async function handleMessage(message, sender) { } } +/** + * Handle AI scan request from content script + */ +async function handleAIScan(data) { + if (!aiDetector) { + const settings = await storage.getSettings(); + if (settings?.ai?.enabled && settings.ai.apiKey) { + await initAIDetector(settings); + } + if (!aiDetector) { + return { results: [], error: 'AI detector not available' }; + } + } + + try { + const results = await aiDetector.scanElements(data.elements, data.domain); + return { results }; + } catch (error) { + log.error('AI scan error:', error); + return { results: [], error: error.message }; + } +} + /** * Toggle blocking for a specific site */ @@ -201,6 +453,9 @@ async function toggleSiteBlocking(hostname, enabled) { } await storage.updateSettings({ whitelist: settings.whitelist }); + const updatedSettings = await storage.getSettings(); + await syncContentScriptRegistrations(updatedSettings); + await syncYouTubeSessionRules(updatedSettings); return { success: true, whitelist: settings.whitelist }; } @@ -214,7 +469,8 @@ async function handleBugReport(data) { userAgent: data.userAgent, url: data.url, description: data.description, - logs: DEBUG_MODE ? data.logs : '[Debug mode disabled]' + logs: DEBUG_MODE ? data.logs : '[Debug mode disabled]', + diagnostics: DEBUG_MODE ? await youtubeDiagnostics.exportSnapshot(20) : '[YouTube diagnostics disabled]' }; log.info('Bug report generated:', reportData); @@ -296,7 +552,7 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { // Only inject scripts when the extension is enabled for this site if (isEnabled) { - if (isYouTube && settings.youtube?.enabled !== false) { + if (isYouTube && settings.youtube?.enabled !== false && !youtubeRegistrationsActive) { // Inject YouTube main-world script chrome.scripting.executeScript({ target: { tabId, allFrames: true }, @@ -313,7 +569,7 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { chrome.scripting.executeScript({ target: { tabId, allFrames: true }, - files: ['src/content/youtube.js'], + files: ['src/content/youtube-utils.js', 'src/content/youtube.js'], injectImmediately: true }).catch(() => {}); } else if (!isYouTube) { @@ -336,6 +592,33 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { files: ['src/content/anti-adblock.js'], injectImmediately: true }).catch(() => {}); + + // Inject AI scanner and video ad interceptor if enabled + if (settings.ai?.enabled && settings.ai.apiKey) { + chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + files: ['src/content/player-mainworld-patch.js'], + world: 'MAIN', + injectImmediately: true + }).catch(() => {}); + + chrome.scripting.insertCSS({ + target: { tabId, allFrames: true }, + files: ['src/content/ai-scanner.css'] + }).catch(() => {}); + + chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + files: ['src/content/ai-scanner.js'], + injectImmediately: true + }).catch(() => {}); + + chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + files: ['src/content/video-ad-interceptor.js'], + injectImmediately: true + }).catch(() => {}); + } } } catch (error) { log.debug('Script injection error:', error.message); @@ -383,6 +666,7 @@ if (chrome.declarativeNetRequest?.onRuleMatchedDebug) { chrome.declarativeNetRequest.onRuleMatchedDebug.addListener((info) => { log.debug('Rule matched:', info.rule.ruleId, info.request.url); stats.incrementBlocked('network', new URL(info.request.url).hostname); + youtubeDiagnostics.recordRuleMatch(info); }); } diff --git a/src/background/rules.js b/src/background/rules.js index 1928215..579ce02 100644 --- a/src/background/rules.js +++ b/src/background/rules.js @@ -10,14 +10,14 @@ export class RulesManager { this.customRules = null; this.lastUpdate = null; } - + /** * Initialize rules manager */ async initialize() { await this.loadRules(); } - + /** * Load all rules from storage/files */ @@ -28,27 +28,27 @@ export class RulesManager { fetch(chrome.runtime.getURL('rules/site-selectors.json')), fetch(chrome.runtime.getURL('rules/ad-domains.json')) ]); - + this.selectors = await selectorsResponse.json(); this.domains = await domainsResponse.json(); - + // Load custom rules from storage const result = await chrome.storage.local.get('customRules'); this.customRules = result.customRules || { domains: [], selectors: {} }; - + console.log('[RulesManager] Rules loaded successfully'); } catch (error) { console.error('[RulesManager] Error loading rules:', error); } } - + /** * Reload rules (after custom rules update) */ async reloadRules() { await this.loadRules(); } - + /** * Get selectors for a specific site */ @@ -56,14 +56,14 @@ export class RulesManager { if (!this.selectors) { await this.loadRules(); } - + const result = { site: null, generic: this.selectors?.generic?.selectors || {}, cookieBanners: this.selectors?.cookieBanners?.selectors || [], custom: {} }; - + // Find site-specific selectors const siteMappings = { 'youtube.com': 'youtube', @@ -85,13 +85,16 @@ export class RulesManager { 'www.facebook.com': 'facebook', 'm.facebook.com': 'facebook' }; - + // Check for exact match const siteKey = siteMappings[hostname]; if (siteKey && this.selectors[siteKey]) { result.site = this.selectors[siteKey]; + if (siteKey === 'youtube') { + result.surfaceSelectors = this.getYouTubeSurfaceSelectors(); + } } - + // Check for pattern match in custom rules if (this.customRules?.selectors) { for (const [pattern, selectors] of Object.entries(this.customRules.selectors)) { @@ -100,10 +103,33 @@ export class RulesManager { } } } - + return result; } - + + getYouTubeSurfaceSelectors() { + const youtube = this.selectors?.youtube; + const selectors = youtube?.selectors || {}; + const unique = (items) => [...new Set((items || []).filter(Boolean))]; + + return { + watchPlayer: unique([...(selectors.videoAds || []), ...(selectors.overlayAds || [])]), + watchFeed: unique([...(selectors.sponsoredContent || []), ...(selectors.feedAds || [])]), + search: unique([ + ...(selectors.sponsoredContent || []).filter((selector) => selector.includes('search') || selector.includes('movie')), + 'ytd-search-pyv-renderer' + ]), + shorts: unique(selectors.shortsFeed || []), + sidebar: unique(selectors.sidebar || []), + dialogs: unique((selectors.overlayAds || []).filter((selector) => selector.includes('dialog') || selector.includes('enforcement'))), + premiumUpsell: unique(selectors.premiumUpsell || []) + }; + } + + getYouTubeSessionRuleConfig() { + return this.domains?.youtube?.sessionRuleGroups || {}; + } + /** * Check for rule updates from remote */ @@ -111,62 +137,62 @@ export class RulesManager { try { const settings = await chrome.storage.local.get('settings'); const { updates } = settings.settings || {}; - + if (!updates?.autoUpdate) { return; } - + const updateUrl = updates.updateUrl || 'https://raw.githubusercontent.com/adeclipse/rules/main/'; - + // Fetch version info const versionResponse = await fetch(`${updateUrl}version.json`, { cache: 'no-cache' }); - + if (!versionResponse.ok) { console.log('[RulesManager] No updates available'); return; } - + const versionInfo = await versionResponse.json(); - + // Check if update needed if (versionInfo.version === this.selectors?.version) { console.log('[RulesManager] Rules are up to date'); return; } - + // Fetch updated rules const [newSelectors, newDomains] = await Promise.all([ fetch(`${updateUrl}site-selectors.json`).then(r => r.json()), fetch(`${updateUrl}ad-domains.json`).then(r => r.json()) ]); - + // Store updated rules await chrome.storage.local.set({ cachedSelectors: newSelectors, cachedDomains: newDomains, lastRuleUpdate: new Date().toISOString() }); - + // Reload this.selectors = newSelectors; this.domains = newDomains; - + console.log('[RulesManager] Rules updated to version:', versionInfo.version); } catch (error) { console.error('[RulesManager] Update check failed:', error); } } - + /** * Get all blocked domains */ getAllBlockedDomains() { if (!this.domains) return []; - + const allDomains = []; - + for (const category of Object.values(this.domains)) { if (Array.isArray(category.domains)) { allDomains.push(...category.domains); @@ -174,15 +200,15 @@ export class RulesManager { allDomains.push(...category); } } - + // Add custom domains if (this.customRules?.domains) { allDomains.push(...this.customRules.domains); } - + return [...new Set(allDomains)]; } - + /** * Validate a custom selector */ @@ -194,7 +220,7 @@ export class RulesManager { return { valid: false, error: error.message }; } } - + /** * Validate a custom domain pattern */ diff --git a/src/background/stats.js b/src/background/stats.js index 6d2a9e9..be1abb5 100644 --- a/src/background/stats.js +++ b/src/background/stats.js @@ -37,7 +37,7 @@ export class StatsTracker { this.pendingUpdates = []; this.syncInterval = null; } - + /** * Get all-time stats */ @@ -46,7 +46,7 @@ export class StatsTracker { const result = await chrome.storage.local.get([STATS_KEY, DAILY_STATS_KEY]); const allTime = result[STATS_KEY] || this.getDefaultStats(); const daily = result[DAILY_STATS_KEY] || {}; - + return { allTime, daily, @@ -63,7 +63,7 @@ export class StatsTracker { }; } } - + /** * Get default stats structure */ @@ -84,7 +84,7 @@ export class StatsTracker { topDomains: {} }; } - + /** * Get default day stats */ @@ -97,14 +97,14 @@ export class StatsTracker { dataSaved: 0 }; } - + /** * Get today's date key */ getTodayKey() { return new Date().toISOString().split('T')[0]; } - + /** * Get today's stats */ @@ -113,14 +113,14 @@ export class StatsTracker { const result = await chrome.storage.local.get(DAILY_STATS_KEY); const daily = result[DAILY_STATS_KEY] || {}; const todayKey = this.getTodayKey(); - + return daily[todayKey] || this.getDefaultDayStats(); } catch (error) { console.error('[StatsTracker] Error getting today stats:', error); return this.getDefaultDayStats(); } } - + /** * Increment blocked count */ @@ -128,11 +128,11 @@ export class StatsTracker { try { // Update session this.sessionStats.adsBlocked++; - + // Calculate estimated data saved const dataSaved = AD_SIZES[type] || AD_SIZES.network; this.sessionStats.dataSaved += dataSaved; - + // Queue persistent update this.pendingUpdates.push({ type: 'blocked', @@ -140,14 +140,14 @@ export class StatsTracker { domain, dataSaved }); - + // Debounced sync this.scheduleSync(); } catch (error) { console.error('[StatsTracker] Error incrementing blocked:', error); } } - + /** * Record ad skip */ @@ -156,19 +156,19 @@ export class StatsTracker { // Update session this.sessionStats.adsSkipped++; this.sessionStats.timeSaved += duration; - + // Queue persistent update this.pendingUpdates.push({ type: 'skipped', duration }); - + this.scheduleSync(); } catch (error) { console.error('[StatsTracker] Error recording skip:', error); } } - + /** * Schedule debounced sync */ @@ -176,63 +176,63 @@ export class StatsTracker { if (this.syncInterval) { clearTimeout(this.syncInterval); } - + this.syncInterval = setTimeout(() => { this.sync(); }, 1000); // Sync after 1 second of inactivity } - + /** * Sync pending updates to storage */ async sync() { if (this.pendingUpdates.length === 0) return; - + try { const updates = [...this.pendingUpdates]; this.pendingUpdates = []; - + const result = await chrome.storage.local.get([STATS_KEY, DAILY_STATS_KEY]); const allTime = result[STATS_KEY] || this.getDefaultStats(); const daily = result[DAILY_STATS_KEY] || {}; const todayKey = this.getTodayKey(); - + if (!daily[todayKey]) { daily[todayKey] = this.getDefaultDayStats(); } - + for (const update of updates) { if (update.type === 'blocked') { allTime.adsBlocked++; allTime.dataSaved += update.dataSaved; allTime.byType[update.adType] = (allTime.byType[update.adType] || 0) + 1; - + if (update.domain) { allTime.topDomains[update.domain] = (allTime.topDomains[update.domain] || 0) + 1; } - + daily[todayKey].adsBlocked++; daily[todayKey].dataSaved += update.dataSaved; } else if (update.type === 'skipped') { allTime.adsSkipped++; allTime.timeSaved += update.duration; - + daily[todayKey].adsSkipped++; daily[todayKey].timeSaved += update.duration; } } - + // Clean up old daily stats (keep last 30 days) const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); const cutoff = thirtyDaysAgo.toISOString().split('T')[0]; - + for (const key of Object.keys(daily)) { if (key < cutoff) { delete daily[key]; } } - + await chrome.storage.local.set({ [STATS_KEY]: allTime, [DAILY_STATS_KEY]: daily @@ -241,7 +241,7 @@ export class StatsTracker { console.error('[StatsTracker] Sync error:', error); } } - + /** * Reset all stats */ @@ -253,7 +253,7 @@ export class StatsTracker { timeSaved: 0, dataSaved: 0 }; - + await chrome.storage.local.set({ [STATS_KEY]: this.getDefaultStats(), [DAILY_STATS_KEY]: {} @@ -263,7 +263,7 @@ export class StatsTracker { throw error; } } - + /** * Format time for display */ @@ -278,7 +278,7 @@ export class StatsTracker { return `${hours}h ${mins}m`; } } - + /** * Format data size for display */ diff --git a/src/background/storage.js b/src/background/storage.js index a2da9d2..b6dd455 100644 --- a/src/background/storage.js +++ b/src/background/storage.js @@ -7,7 +7,7 @@ const DEFAULT_SETTINGS = { enabled: true, mode: 'balanced', // 'light', 'balanced', 'aggressive' debugMode: false, - + // Block types blockTypes: { videoAds: true, @@ -20,27 +20,31 @@ const DEFAULT_SETTINGS = { newsletterPopups: false, socialWidgets: false }, - + // YouTube-specific youtube: { enabled: true, autoSkip: true, speedUpAds: true, muteAds: true, + useDocumentStartScripts: true, + diagnosticsEnabled: true, + diagnosticsMaxEntries: 120, + sessionRuleProfile: 'balanced', blockOverlays: true, blockMasthead: true, blockSponsored: true, blockMerch: true, blockEndCards: false }, - + // Site lists whitelist: [], blacklist: [], // Website ad blocking mode: 'all' = block ads on all sites, 'manual' = only block on blacklisted sites websiteMode: 'manual', - + // Performance performance: { lazyLoad: true, @@ -49,7 +53,7 @@ const DEFAULT_SETTINGS = { cacheEnabled: true, useML: false // TensorFlow.js integration }, - + // UI preferences ui: { showBadge: true, @@ -57,12 +61,31 @@ const DEFAULT_SETTINGS = { darkMode: 'auto', compactMode: false }, - + // Update settings updates: { autoUpdate: true, updateUrl: 'https://raw.githubusercontent.com/adeclipse/rules/main/', lastUpdate: null + }, + + // AI-powered ad detection via LLM APIs + ai: { + enabled: false, + provider: 'openai', + apiKey: '', + model: '', + customBaseUrl: '', + customModelName: '', + confidenceThreshold: 0.7, + scanMode: 'smart', // 'smart' | 'ai-only' | 'ai-assist' + maxElementsPerBatch: 30, + cacheDurationHours: 24, + scanOnLoad: true, + continuousScan: true, + smoothRemoval: true, + showAiBadge: true, + usageStats: { totalTokens: 0, totalRequests: 0 } } }; @@ -72,21 +95,21 @@ export class StorageManager { this.cacheTimeout = 5000; // 5 seconds this.lastCacheTime = 0; } - + /** * Get all settings */ async getSettings() { const now = Date.now(); - + // Return cached if valid if (this.cache && (now - this.lastCacheTime) < this.cacheTimeout) { return this.cache; } - + try { const result = await chrome.storage.local.get('settings'); - this.cache = result.settings || DEFAULT_SETTINGS; + this.cache = this.deepMerge(DEFAULT_SETTINGS, result.settings || {}); this.lastCacheTime = now; return this.cache; } catch (error) { @@ -94,7 +117,7 @@ export class StorageManager { return DEFAULT_SETTINGS; } } - + /** * Update settings (partial update) */ @@ -110,21 +133,23 @@ export class StorageManager { throw error; } } - + /** * Initialize default settings */ async initializeDefaults() { try { const existing = await chrome.storage.local.get('settings'); - if (!existing.settings) { - await chrome.storage.local.set({ settings: DEFAULT_SETTINGS }); + const merged = this.deepMerge(DEFAULT_SETTINGS, existing.settings || {}); + + if (!existing.settings || JSON.stringify(existing.settings) !== JSON.stringify(merged)) { + await chrome.storage.local.set({ settings: merged }); } } catch (error) { console.error('[StorageManager] Error initializing defaults:', error); } } - + /** * Get custom rules */ @@ -140,7 +165,7 @@ export class StorageManager { return { domains: [], selectors: {} }; } } - + /** * Save custom rules */ @@ -152,7 +177,7 @@ export class StorageManager { throw error; } } - + /** * Export all data */ @@ -169,7 +194,7 @@ export class StorageManager { throw error; } } - + /** * Import all data */ @@ -178,13 +203,13 @@ export class StorageManager { if (!importData.data) { throw new Error('Invalid import data'); } - + // Clear existing data await chrome.storage.local.clear(); - + // Import new data await chrome.storage.local.set(importData.data); - + // Clear cache this.cache = null; } catch (error) { @@ -192,21 +217,35 @@ export class StorageManager { throw error; } } - + /** * Deep merge helper */ deepMerge(target, source) { + if (Array.isArray(source)) { + return source.slice(); + } + + if (!this.isMergeableObject(target) || !this.isMergeableObject(source)) { + return source; + } + const output = { ...target }; - + for (const key of Object.keys(source)) { - if (source[key] instanceof Object && key in target) { + if (Array.isArray(source[key])) { + output[key] = source[key].slice(); + } else if (this.isMergeableObject(source[key]) && this.isMergeableObject(target[key])) { output[key] = this.deepMerge(target[key], source[key]); } else { output[key] = source[key]; } } - + return output; } + + isMergeableObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); + } } diff --git a/src/background/youtube-diagnostics.js b/src/background/youtube-diagnostics.js new file mode 100644 index 0000000..53ff93f --- /dev/null +++ b/src/background/youtube-diagnostics.js @@ -0,0 +1,243 @@ +const DIAGNOSTICS_KEY = 'adeclipse_youtube_diagnostics'; + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function sanitizeString(value, maxLength = 120) { + return String(value || '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, maxLength); +} + +function sanitizeUrl(value) { + if (!value) return null; + + try { + const parsed = new URL(value); + return parsed.origin + parsed.pathname; + } catch (_) { + return sanitizeString(value, 160) || null; + } +} + +function sanitizeValue(value, depth) { + if (depth > 2 || value == null) return undefined; + + if (typeof value === 'string') { + return sanitizeString(value); + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (Array.isArray(value)) { + return value + .slice(0, 6) + .map((item) => sanitizeValue(item, depth + 1)) + .filter((item) => item !== undefined); + } + + if (!isPlainObject(value)) { + return sanitizeString(value, 80); + } + + const output = {}; + + Object.entries(value) + .slice(0, 8) + .forEach(([key, nestedValue]) => { + const normalizedKey = sanitizeString(key, 40); + const sanitized = normalizedKey.toLowerCase().includes('url') + ? sanitizeUrl(nestedValue) + : sanitizeValue(nestedValue, depth + 1); + + if (sanitized !== undefined && sanitized !== null && sanitized !== '') { + output[normalizedKey] = sanitized; + } + }); + + return Object.keys(output).length ? output : undefined; +} + +function createSummary() { + return { + totalCaptured: 0, + byType: {}, + bySource: {} + }; +} + +export class YouTubeDiagnosticsManager { + constructor() { + this.entries = []; + this.summary = createSummary(); + this.enabled = false; + this.maxEntries = 120; + this.recentKeys = new Map(); + this.persistTimer = null; + } + + async init(settings) { + await this.load(); + this.configure(settings); + } + + configure(settings) { + this.enabled = Boolean(settings?.debugMode || settings?.youtube?.diagnosticsEnabled); + this.maxEntries = settings?.youtube?.diagnosticsMaxEntries || 120; + this.trimEntries(); + } + + async load() { + try { + const result = await chrome.storage.local.get(DIAGNOSTICS_KEY); + const snapshot = result[DIAGNOSTICS_KEY] || {}; + this.entries = Array.isArray(snapshot.entries) ? snapshot.entries : []; + this.summary = isPlainObject(snapshot.summary) ? snapshot.summary : createSummary(); + this.trimEntries(); + } catch (error) { + console.error('[YouTubeDiagnostics] Load error:', error); + this.entries = []; + this.summary = createSummary(); + } + } + + trimEntries() { + if (this.entries.length > this.maxEntries) { + this.entries = this.entries.slice(0, this.maxEntries); + } + } + + shouldCapture() { + return this.enabled; + } + + sanitizeEntry(entry) { + if (!isPlainObject(entry)) return null; + + const normalized = { + id: 'ytd-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8), + timestamp: new Date().toISOString(), + source: sanitizeString(entry.source || 'unknown', 40), + type: sanitizeString(entry.type || 'event', 60), + signal: sanitizeString(entry.signal || '', 80), + pageType: sanitizeString(entry.pageType || '', 40) || null, + url: sanitizeUrl(entry.url), + tabId: Number.isInteger(entry.tabId) ? entry.tabId : null, + request: sanitizeValue(entry.request, 0), + details: sanitizeValue(entry.details, 0) + }; + + return normalized; + } + + buildDedupeKey(entry) { + const detailKey = entry.details ? JSON.stringify(entry.details).slice(0, 160) : ''; + return [entry.source, entry.type, entry.signal, entry.pageType, entry.url, detailKey].join('|'); + } + + schedulePersist() { + if (this.persistTimer) { + clearTimeout(this.persistTimer); + } + + this.persistTimer = setTimeout(() => { + this.persist().catch((error) => { + console.error('[YouTubeDiagnostics] Persist error:', error); + }); + }, 250); + } + + async persist() { + await chrome.storage.local.set({ + [DIAGNOSTICS_KEY]: { + summary: this.summary, + entries: this.entries + } + }); + } + + record(entry) { + if (!this.shouldCapture()) { + return { captured: false }; + } + + const sanitized = this.sanitizeEntry(entry); + if (!sanitized) { + return { captured: false }; + } + + const dedupeKey = this.buildDedupeKey(sanitized); + const now = Date.now(); + const previous = this.recentKeys.get(dedupeKey) || 0; + + if (now - previous < 15000) { + return { captured: false, deduped: true }; + } + + this.recentKeys.set(dedupeKey, now); + this.entries.unshift(sanitized); + this.trimEntries(); + + this.summary.totalCaptured += 1; + this.summary.byType[sanitized.type] = (this.summary.byType[sanitized.type] || 0) + 1; + this.summary.bySource[sanitized.source] = (this.summary.bySource[sanitized.source] || 0) + 1; + + this.schedulePersist(); + return { captured: true, entry: sanitized }; + } + + recordRuleMatch(info) { + const url = info?.request?.url || ''; + const initiator = info?.request?.initiator || ''; + + if (!/youtube\.com|googlevideo\.com|ytimg\.com/i.test(url + ' ' + initiator)) { + return { captured: false }; + } + + return this.record({ + source: 'dnr', + type: 'rule-match', + signal: String(info?.rule?.ruleId || ''), + url, + tabId: info?.request?.tabId, + request: { + url, + initiator, + type: info?.request?.type, + method: info?.request?.method + }, + details: { + rulesetId: info?.rule?.rulesetId || null, + frameType: info?.request?.frameType || null + } + }); + } + + getSnapshot(limit = 50) { + return { + enabled: this.enabled, + summary: this.summary, + totalEntries: this.entries.length, + entries: this.entries.slice(0, limit) + }; + } + + async exportSnapshot(limit = this.maxEntries) { + return { + exportedAt: new Date().toISOString(), + version: chrome.runtime.getManifest().version, + diagnostics: this.getSnapshot(limit) + }; + } + + async clear() { + this.entries = []; + this.summary = createSummary(); + this.recentKeys.clear(); + await this.persist(); + } +} \ No newline at end of file diff --git a/src/background/youtube-session-rules.js b/src/background/youtube-session-rules.js new file mode 100644 index 0000000..7a41c4a --- /dev/null +++ b/src/background/youtube-session-rules.js @@ -0,0 +1,87 @@ +export const YOUTUBE_SESSION_RULE_BASE_ID = 20000; + +function youtubeIsWhitelisted(settings) { + const whitelist = settings?.whitelist || []; + return whitelist.some((hostname) => hostname === 'youtube.com' || hostname.endsWith('.youtube.com')); +} + +function isEnabledBySetting(settings, gate) { + switch (gate) { + case 'videoAds': + return settings?.blockTypes?.videoAds !== false; + case 'sponsoredContent': + return settings?.blockTypes?.sponsoredContent !== false && settings?.youtube?.blockSponsored !== false; + case 'trackers': + return settings?.blockTypes?.trackers !== false; + default: + return true; + } +} + +function normalizeRuleTemplate(groupName, template, index) { + return { + id: YOUTUBE_SESSION_RULE_BASE_ID + index, + groupName, + priority: template.priority || 4, + resourceTypes: template.resourceTypes || ['xmlhttprequest'], + urlFilter: template.urlFilter || null, + regexFilter: template.regexFilter || null, + initiatorDomains: template.initiatorDomains || ['youtube.com'], + requestDomains: template.requestDomains, + requestMethods: template.requestMethods, + settingGate: template.settingGate || null + }; +} + +export function buildYouTubeSessionRules(settings, youtubeDomainConfig = {}) { + if (!settings?.enabled || settings?.youtube?.enabled === false || youtubeIsWhitelisted(settings)) { + return []; + } + + const sessionRuleGroups = youtubeDomainConfig.sessionRuleGroups || {}; + const rules = []; + let index = 0; + + Object.entries(sessionRuleGroups).forEach(([groupName, templates]) => { + const list = Array.isArray(templates) ? templates : []; + + list.forEach((template) => { + const normalized = normalizeRuleTemplate(groupName, template, index); + index += 1; + + if (normalized.settingGate && !isEnabledBySetting(settings, normalized.settingGate)) { + return; + } + + const condition = { + resourceTypes: normalized.resourceTypes, + initiatorDomains: normalized.initiatorDomains + }; + + if (normalized.requestDomains) { + condition.requestDomains = normalized.requestDomains; + } + + if (normalized.requestMethods) { + condition.requestMethods = normalized.requestMethods; + } + + if (normalized.regexFilter) { + condition.regexFilter = normalized.regexFilter; + } else if (normalized.urlFilter) { + condition.urlFilter = normalized.urlFilter; + } else { + return; + } + + rules.push({ + id: normalized.id, + priority: normalized.priority, + action: { type: 'block' }, + condition + }); + }); + }); + + return rules; +} \ No newline at end of file diff --git a/src/content/ai-scanner.css b/src/content/ai-scanner.css new file mode 100644 index 0000000..b326325 --- /dev/null +++ b/src/content/ai-scanner.css @@ -0,0 +1,84 @@ +/* AdEclipse AI Scanner - Smooth Removal Animations */ + +.adeclipse-ai-fade-out { + opacity: 0 !important; + transition: opacity 300ms ease-out !important; + pointer-events: none !important; +} + +.adeclipse-ai-collapsing { + max-height: 0 !important; + margin-top: 0 !important; + margin-bottom: 0 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + border-top-width: 0 !important; + border-bottom-width: 0 !important; + overflow: hidden !important; + transition: max-height 250ms ease-out, + margin 250ms ease-out, + padding 250ms ease-out, + border-width 250ms ease-out !important; + pointer-events: none !important; +} + +.adeclipse-ai-hidden { + display: none !important; + height: 0 !important; + min-height: 0 !important; + max-height: 0 !important; + margin: 0 !important; + padding: 0 !important; + border: none !important; + overflow: hidden !important; +} + +/* Collapsed parent containers that held only ads */ +.adeclipse-ai-empty-container { + display: none !important; + height: 0 !important; + min-height: 0 !important; + max-height: 0 !important; + margin: 0 !important; + padding: 0 !important; + border: none !important; + overflow: hidden !important; + line-height: 0 !important; + font-size: 0 !important; +} + +/* Override min-height / height set inline on ad containers and their parents */ +[style*="min-height"]:has(> .adeclipse-ai-hidden), +[style*="height"]:has(> .adeclipse-ai-hidden) { + min-height: 0 !important; + height: auto !important; +} + +/* Debug mode: highlight detected ads before removal */ +.adeclipse-ai-detected { + outline: 2px dashed rgba(239, 68, 68, 0.6) !important; + outline-offset: -2px; + position: relative; +} + +.adeclipse-ai-detected::after { + content: 'AI: Ad Detected'; + position: absolute; + top: 2px; + right: 2px; + background: rgba(239, 68, 68, 0.85); + color: white; + font-size: 10px; + font-family: system-ui, -apple-system, sans-serif; + padding: 2px 6px; + border-radius: 3px; + z-index: 999999; + pointer-events: none; + line-height: 1.4; +} + +/* Scanning indicator */ +.adeclipse-ai-scanning { + outline: 1px dashed rgba(59, 130, 246, 0.3) !important; + outline-offset: -1px; +} diff --git a/src/content/ai-scanner.js b/src/content/ai-scanner.js new file mode 100644 index 0000000..f763c5b --- /dev/null +++ b/src/content/ai-scanner.js @@ -0,0 +1,428 @@ +/** + * AdEclipse AI Scanner - Content Script + * Extracts page element metadata, sends to background for LLM analysis, + * and smoothly removes detected ads. + */ + +(function () { + 'use strict'; + + if (window.__adeclipse_ai_scanner_loaded) return; + window.__adeclipse_ai_scanner_loaded = true; + + const MIN_ELEMENT_SIZE = 50; + const SCAN_DEBOUNCE_MS = 500; + const MAX_TEXT_LENGTH = 200; + const MAX_SCAN_ELEMENTS = 60; + + const AD_HINT_SELECTORS = [ + 'iframe[src*="ad"]', 'iframe[src*="doubleclick"]', 'iframe[src*="googlesyndication"]', + '[class*="ad-"]', '[class*="ad_"]', '[class*="ads-"]', '[class*="ads_"]', + '[class*="advert"]', '[class*="sponsor"]', '[class*="promo"]', + '[id*="ad-"]', '[id*="ad_"]', '[id*="ads-"]', '[id*="ads_"]', + '[data-ad]', '[data-ad-slot]', '[data-ad-unit]', '[data-adunit]', + '[class*="taboola"]', '[class*="outbrain"]', '[id*="taboola"]', '[id*="outbrain"]', + '[class*="native-ad"]', '[class*="sponsored"]', '[class*="promoted"]' + ]; + + const SKIP_TAGS = new Set([ + 'HTML', 'HEAD', 'BODY', 'SCRIPT', 'STYLE', 'LINK', 'META', 'NOSCRIPT', 'BR', 'HR' + ]); + + const CONTENT_TAGS = new Set([ + 'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI', + 'TABLE', 'THEAD', 'TBODY', 'TR', 'TD', 'TH', + 'PRE', 'CODE', 'BLOCKQUOTE', 'FORM', 'INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', + 'LABEL', 'FIELDSET', 'LEGEND' + ]); + + let config = { + enabled: false, + scanMode: 'smart', + smoothRemoval: true, + debugMode: false, + confidenceThreshold: 0.7 + }; + + let scanTimer = null; + let observer = null; + let processedElements = new WeakSet(); + let pendingScan = false; + let elementCounter = 0; + + async function initialize() { + try { + const response = await chrome.runtime.sendMessage({ type: 'AI_GET_CONFIG' }); + if (!response || !response.enabled) return; + + config = { ...config, ...response }; + setupObserver(); + + if (config.scanOnLoad !== false) { + scheduleScan(); + } + } catch (error) { + // Extension context may be invalid - silently exit + } + } + + function setupObserver() { + if (observer) observer.disconnect(); + + observer = new MutationObserver((mutations) => { + let hasRelevantChanges = false; + for (const mutation of mutations) { + if (mutation.addedNodes.length > 0) { + for (const node of mutation.addedNodes) { + if (node.nodeType === 1 && !SKIP_TAGS.has(node.tagName)) { + hasRelevantChanges = true; + break; + } + } + } + if (hasRelevantChanges) break; + } + + if (hasRelevantChanges && config.continuousScan !== false) { + scheduleScan(); + } + }); + + observer.observe(document.body || document.documentElement, { + childList: true, + subtree: true + }); + } + + function scheduleScan() { + if (pendingScan) return; + clearTimeout(scanTimer); + scanTimer = setTimeout(() => runScan(), SCAN_DEBOUNCE_MS); + } + + async function runScan() { + if (pendingScan) return; + pendingScan = true; + + try { + const candidates = collectCandidates(); + if (candidates.length === 0) { + pendingScan = false; + return; + } + + const descriptors = candidates.map(({ element }) => extractMetadata(element)); + + const response = await chrome.runtime.sendMessage({ + type: 'AI_SCAN_ELEMENTS', + data: { + elements: descriptors, + domain: window.location.hostname + } + }); + + if (response && response.results) { + applyVerdicts(response.results, candidates); + } + } catch (error) { + if (!error.message?.includes('Extension context invalidated')) { + console.error('[AdEclipse AI Scanner] Scan error:', error); + } + } finally { + pendingScan = false; + } + } + + function collectCandidates() { + const candidates = []; + const allElements = []; + + if (config.scanMode === 'smart') { + const hintSelector = AD_HINT_SELECTORS.join(', '); + try { + const hinted = document.querySelectorAll(hintSelector); + for (const el of hinted) { + if (isValidCandidate(el)) { + allElements.push({ element: el, score: 2 }); + } + } + } catch (e) { /* invalid selector */ } + + const topLevel = document.querySelectorAll('div, section, aside, article, ins, iframe, figure'); + for (const el of topLevel) { + if (isValidCandidate(el) && !allElements.some(c => c.element === el)) { + const score = quickSuspicionScore(el); + if (score > 0) { + allElements.push({ element: el, score }); + } + } + } + } else { + const topLevel = document.querySelectorAll('div, section, aside, article, ins, iframe, figure, span, a'); + for (const el of topLevel) { + if (isValidCandidate(el)) { + allElements.push({ element: el, score: 1 }); + } + } + } + + allElements.sort((a, b) => b.score - a.score); + + const seen = new WeakSet(); + for (const item of allElements) { + if (candidates.length >= MAX_SCAN_ELEMENTS) break; + + if (seen.has(item.element)) continue; + seen.add(item.element); + + let dominated = false; + for (const existing of candidates) { + if (existing.element.contains(item.element) || item.element.contains(existing.element)) { + dominated = true; + break; + } + } + if (!dominated) { + candidates.push(item); + } + } + + return candidates; + } + + function isValidCandidate(el) { + if (processedElements.has(el)) return false; + if (SKIP_TAGS.has(el.tagName)) return false; + if (el.closest('.adeclipse-ai-hidden, .adeclipse-ai-fade-out')) return false; + + const rect = el.getBoundingClientRect(); + if (rect.width < MIN_ELEMENT_SIZE || rect.height < MIN_ELEMENT_SIZE) return false; + if (rect.bottom < 0 || rect.top > window.innerHeight * 3) return false; + + return true; + } + + function quickSuspicionScore(el) { + let score = 0; + const attrs = `${el.id || ''} ${el.className || ''}`.toLowerCase(); + + if (/\bad[s]?\b|advert|banner|sponsor|promo/i.test(attrs)) score += 2; + if (/taboola|outbrain|mgid|revcontent|zergnet/i.test(attrs)) score += 3; + if (/google[_-]?ad|dfp|doubleclick|adsense/i.test(attrs)) score += 3; + if (el.querySelector('iframe')) score += 1; + + const rect = el.getBoundingClientRect(); + const COMMON_AD_SIZES = [ + [728, 90], [300, 250], [160, 600], [320, 50], + [300, 600], [970, 90], [970, 250], [336, 280] + ]; + for (const [w, h] of COMMON_AD_SIZES) { + if (Math.abs(rect.width - w) < 15 && Math.abs(rect.height - h) < 15) { + score += 2; + break; + } + } + + const text = (el.textContent || '').toLowerCase(); + if (/\bsponsored\b|\badvertisement\b|\bpromoted\b|\bad\b/.test(text.slice(0, 100))) score += 1; + + const links = el.querySelectorAll('a'); + let externalCount = 0; + for (const link of links) { + try { + if (link.href && !link.href.includes(window.location.hostname)) externalCount++; + } catch (e) { /* ignore */ } + } + if (links.length > 3 && externalCount === links.length) score += 1; + + const dataAttrs = Array.from(el.attributes).filter(a => a.name.startsWith('data-')); + if (dataAttrs.some(a => /ad|slot|unit|campaign|sponsor/i.test(a.name + a.value))) score += 2; + + return score; + } + + function extractMetadata(element) { + const rect = element.getBoundingClientRect(); + const elId = `el_${elementCounter++}`; + + element.__adeclipse_scan_id = elId; + + const classes = element.className + ? (typeof element.className === 'string' ? element.className.split(/\s+/).filter(Boolean) : []) + : []; + + const text = (element.textContent || '').trim().slice(0, MAX_TEXT_LENGTH); + const links = element.querySelectorAll('a'); + let externalLinkCount = 0; + for (const link of links) { + try { + if (link.href && !link.href.includes(window.location.hostname)) externalLinkCount++; + } catch (e) { /* ignore */ } + } + + const childTags = []; + for (let i = 0; i < Math.min(element.children.length, 10); i++) { + childTags.push(element.children[i].tagName.toLowerCase()); + } + + const dataAttributes = Array.from(element.attributes) + .filter(a => a.name.startsWith('data-')) + .map(a => `${a.name}=${a.value}`.slice(0, 60)); + + let position = 'middle'; + if (rect.top < 200) position = 'top'; + else if (rect.top > window.innerHeight - 200) position = 'bottom'; + if (rect.left > window.innerWidth * 0.7) position += '-right'; + else if (rect.right < window.innerWidth * 0.3) position += '-left'; + + return { + id: elId, + tag: element.tagName.toLowerCase(), + classes, + elId: element.id || undefined, + text: text || undefined, + width: Math.round(rect.width), + height: Math.round(rect.height), + position, + childTags, + hasIframe: element.querySelector('iframe') !== null, + hasVideo: element.querySelector('video') !== null, + hasImage: element.querySelector('img') !== null, + linkCount: links.length, + externalLinkCount, + dataAttributes: dataAttributes.length ? dataAttributes : undefined, + ariaLabel: element.getAttribute('aria-label') || undefined, + role: element.getAttribute('role') || undefined, + src: element.tagName === 'IFRAME' ? (element.src || '').slice(0, 100) : undefined + }; + } + + function applyVerdicts(verdicts, candidates) { + for (const verdict of verdicts) { + if (!verdict.isAd) continue; + + const candidate = candidates.find(c => c.element.__adeclipse_scan_id === verdict.elementId); + if (!candidate) continue; + + const element = candidate.element; + processedElements.add(element); + + if (config.debugMode) { + element.classList.add('adeclipse-ai-detected'); + element.title = `AdEclipse AI: ${verdict.adType} (${Math.round(verdict.confidence * 100)}%) - ${verdict.reason}`; + setTimeout(() => removeElement(element), 2000); + } else { + removeElement(element); + } + + try { + chrome.runtime.sendMessage({ + type: 'INCREMENT_BLOCKED', + data: { type: verdict.adType || 'ai-detected', domain: window.location.hostname } + }); + } catch (e) { /* ignore */ } + } + } + + function removeElement(element) { + if (!element || !element.parentNode) return; + + if (!config.smoothRemoval) { + element.classList.add('adeclipse-ai-hidden'); + cleanupAfterRemoval(element); + collapseEmptyAncestors(element); + return; + } + + const rect = element.getBoundingClientRect(); + element.style.setProperty('max-height', `${rect.height}px`, 'important'); + element.style.setProperty('overflow', 'hidden', 'important'); + + requestAnimationFrame(() => { + element.classList.add('adeclipse-ai-fade-out'); + + setTimeout(() => { + element.classList.add('adeclipse-ai-collapsing'); + + setTimeout(() => { + element.classList.add('adeclipse-ai-hidden'); + element.classList.remove('adeclipse-ai-fade-out', 'adeclipse-ai-collapsing'); + element.style.removeProperty('max-height'); + element.style.removeProperty('overflow'); + cleanupAfterRemoval(element); + collapseEmptyAncestors(element); + }, 280); + }, 320); + }); + } + + function cleanupAfterRemoval(element) { + try { + const iframes = element.querySelectorAll('iframe'); + for (const iframe of iframes) { + iframe.src = 'about:blank'; + } + const videos = element.querySelectorAll('video'); + for (const video of videos) { + video.pause(); + video.src = ''; + } + } catch (e) { /* ignore */ } + } + + const STRUCTURAL_TAGS = new Set([ + 'HTML', 'BODY', 'MAIN', 'ARTICLE', 'HEADER', 'FOOTER', 'NAV' + ]); + + function collapseEmptyAncestors(element) { + let parent = element.parentElement; + const MAX_DEPTH = 6; + let depth = 0; + + while (parent && depth < MAX_DEPTH) { + if (STRUCTURAL_TAGS.has(parent.tagName)) break; + if (parent.id && /^(content|main|app|root|wrapper|page)$/i.test(parent.id)) break; + + if (isEffectivelyEmpty(parent)) { + parent.classList.add('adeclipse-ai-empty-container'); + parent.style.setProperty('min-height', '0', 'important'); + parent.style.setProperty('height', '0', 'important'); + parent.style.setProperty('margin', '0', 'important'); + parent.style.setProperty('padding', '0', 'important'); + parent = parent.parentElement; + depth++; + } else { + parent.style.setProperty('min-height', '0', 'important'); + break; + } + } + } + + function isEffectivelyEmpty(el) { + for (const child of el.children) { + if (child.classList.contains('adeclipse-ai-hidden') || + child.classList.contains('adeclipse-ai-empty-container')) { + continue; + } + const style = getComputedStyle(child); + if (style.display === 'none' || style.visibility === 'hidden') continue; + + const rect = child.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) return false; + } + + let directText = ''; + for (const node of el.childNodes) { + if (node.nodeType === 3) directText += node.textContent; + } + if (directText.trim().length > 0) return false; + + return true; + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initialize); + } else { + initialize(); + } +})(); diff --git a/src/content/anti-adblock.js b/src/content/anti-adblock.js index ef38a97..1119626 100644 --- a/src/content/anti-adblock.js +++ b/src/content/anti-adblock.js @@ -3,13 +3,13 @@ * Handles anti-adblock detection bypassing */ -(function() { +(function () { 'use strict'; - + // Prevent multiple injections if (window.__ADECLIPSE_ANTI_LOADED__) return; window.__ADECLIPSE_ANTI_LOADED__ = true; - + /** * Spoof ad-related globals that sites check for */ @@ -20,7 +20,7 @@ fakeAd.className = 'ad ads adsbox ad-placement doubleclick'; fakeAd.innerHTML = ' '; fakeAd.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;'; - + // Insert into DOM (hidden) if (document.body) { document.body.appendChild(fakeAd); @@ -29,23 +29,23 @@ document.body.appendChild(fakeAd); }); } - + // Spoof common detection variables try { Object.defineProperty(window, 'adsbygoogle', { - value: { loaded: true, push: () => {} }, + value: { loaded: true, push: () => { } }, writable: false, configurable: false }); - } catch (e) {} - + } catch (e) { } + try { Object.defineProperty(window, 'google_ad_client', { value: 'ca-pub-0000000000000000', writable: true }); - } catch (e) {} - + } catch (e) { } + // Spoof DoubleClick try { window.googletag = window.googletag || {}; @@ -56,65 +56,65 @@ window.googletag.addService = () => window.googletag; window.googletag.setTargeting = () => window.googletag; window.googletag.pubads = () => ({ - set: () => {}, + set: () => { }, get: () => null, - setTargeting: () => {}, - clearTargeting: () => {}, - enableSingleRequest: () => {}, - collapseEmptyDivs: () => {}, - enableLazyLoad: () => {}, - refresh: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - disableInitialLoad: () => {}, - updateCorrelator: () => {}, + setTargeting: () => { }, + clearTargeting: () => { }, + enableSingleRequest: () => { }, + collapseEmptyDivs: () => { }, + enableLazyLoad: () => { }, + refresh: () => { }, + addEventListener: () => { }, + removeEventListener: () => { }, + disableInitialLoad: () => { }, + updateCorrelator: () => { }, getSlots: () => [], getTargeting: () => [], getTargetingKeys: () => [], - clear: () => {} + clear: () => { } }); - window.googletag.enableServices = () => {}; - window.googletag.display = () => {}; + window.googletag.enableServices = () => { }; + window.googletag.display = () => { }; window.googletag.companionAds = () => ({ - setRefreshUnfilledSlots: () => {} + setRefreshUnfilledSlots: () => { } }); - } catch (e) {} + } catch (e) { } } - + /** * Override methods that detect ad blocking */ function overrideDetectionMethods() { // Override fetch to spoof ad requests const originalFetch = window.fetch; - window.fetch = function(url, options) { + window.fetch = function (url, options) { const urlString = typeof url === 'string' ? url : url.url; - + // Check if this is an ad-detection request if (isAdDetectionRequest(urlString)) { return Promise.resolve(new Response('', { status: 200 })); } - + return originalFetch.apply(this, arguments); }; - + // Override XMLHttpRequest const originalXHROpen = XMLHttpRequest.prototype.open; const originalXHRSend = XMLHttpRequest.prototype.send; - - XMLHttpRequest.prototype.open = function(method, url, ...args) { + + XMLHttpRequest.prototype.open = function (method, url, ...args) { this._adeclipse_url = url; return originalXHROpen.apply(this, [method, url, ...args]); }; - - XMLHttpRequest.prototype.send = function(body) { + + XMLHttpRequest.prototype.send = function (body) { if (isAdDetectionRequest(this._adeclipse_url)) { // Fake successful response Object.defineProperty(this, 'status', { value: 200 }); Object.defineProperty(this, 'statusText', { value: 'OK' }); Object.defineProperty(this, 'response', { value: '' }); Object.defineProperty(this, 'responseText', { value: '' }); - + setTimeout(() => { if (this.onload) this.onload(); if (this.onreadystatechange) { @@ -124,15 +124,15 @@ }, 10); return; } - + return originalXHRSend.apply(this, arguments); }; - + // Override element dimension checks const originalGetComputedStyle = window.getComputedStyle; - window.getComputedStyle = function(element, pseudoElt) { + window.getComputedStyle = function (element, pseudoElt) { const result = originalGetComputedStyle.apply(this, arguments); - + // If checking an ad test element, return visible dimensions if (element && isAdTestElement(element)) { return new Proxy(result, { @@ -145,15 +145,15 @@ } }); } - + return result; }; - + // Override getBoundingClientRect for ad test elements const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; - Element.prototype.getBoundingClientRect = function() { + Element.prototype.getBoundingClientRect = function () { const result = originalGetBoundingClientRect.apply(this, arguments); - + if (isAdTestElement(this)) { return { top: result.top, @@ -167,39 +167,39 @@ toJSON: () => ({}) }; } - + return result; }; - + // Override offsetWidth/offsetHeight const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth'); const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'); - + if (originalOffsetWidth) { Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { - get: function() { + get: function () { if (isAdTestElement(this)) return 300; return originalOffsetWidth.get.call(this); } }); } - + if (originalOffsetHeight) { Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { - get: function() { + get: function () { if (isAdTestElement(this)) return 250; return originalOffsetHeight.get.call(this); } }); } } - + /** * Check if URL is an ad detection request */ function isAdDetectionRequest(url) { if (!url) return false; - + const patterns = [ 'adblock', 'ad-block', @@ -214,31 +214,31 @@ 'fuckadblock', 'blockerdetector' ]; - + const urlLower = url.toLowerCase(); return patterns.some(pattern => urlLower.includes(pattern)); } - + /** * Check if element is used for ad block detection */ function isAdTestElement(element) { if (!element) return false; - + const className = element.className?.toString().toLowerCase() || ''; const id = element.id?.toLowerCase() || ''; - + const testPatterns = [ 'ad-test', 'adtest', 'adsbox', 'ad-box', 'ad_box', 'ad-banner', 'adbanner', 'ads-banner', 'banner-ad', 'textads', 'text-ads', 'sponsor-ads', 'doubleclick' ]; - - return testPatterns.some(pattern => + + return testPatterns.some(pattern => className.includes(pattern) || id.includes(pattern) ); } - + /** * Block common anti-adblock scripts */ @@ -250,7 +250,7 @@ if (node.tagName === 'SCRIPT') { const src = node.src?.toLowerCase() || ''; const content = node.textContent?.toLowerCase() || ''; - + // Check for anti-adblock patterns const patterns = [ 'blockadblock', @@ -260,7 +260,7 @@ 'adblockdetector', 'detectadblock' ]; - + if (patterns.some(p => src.includes(p) || content.includes(p))) { node.remove(); console.log('[AdEclipse] Blocked anti-adblock script'); @@ -269,13 +269,13 @@ } } }); - + observer.observe(document.documentElement, { childList: true, subtree: true }); } - + /** * Handle modal/overlay anti-adblock messages */ @@ -284,7 +284,7 @@ for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType !== Node.ELEMENT_NODE) continue; - + // Check if this is an anti-adblock modal const text = node.textContent?.toLowerCase() || ''; const hasAdblockText = [ @@ -296,13 +296,13 @@ 'whitelist this site', 'disable adblock' ].some(t => text.includes(t)); - + if (hasAdblockText) { // Check if it's a modal/overlay const style = window.getComputedStyle(node); if (style.position === 'fixed' || style.position === 'absolute') { node.remove(); - + // Also remove overlay const overlays = document.querySelectorAll('[style*="position: fixed"], [style*="position:fixed"]'); for (const overlay of overlays) { @@ -311,24 +311,24 @@ overlay.remove(); } } - + // Restore scroll document.body.style.overflow = ''; document.documentElement.style.overflow = ''; - + console.log('[AdEclipse] Removed anti-adblock modal'); } } } } }); - + observer.observe(document.documentElement, { childList: true, subtree: true }); } - + /** * Initialize anti-adblock bypass */ @@ -336,7 +336,7 @@ spoofAdGlobals(); overrideDetectionMethods(); blockAntiAdblockScripts(); - + // Wait for DOM to handle modals if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', handleAntiAdblockModals); @@ -344,7 +344,7 @@ handleAntiAdblockModals(); } } - + // Run immediately initialize(); })(); diff --git a/src/content/general.css b/src/content/general.css index daa0339..56276c5 100644 --- a/src/content/general.css +++ b/src/content/general.css @@ -298,16 +298,50 @@ iframe[src*="/ad/"] { /* ================================ Layout Fixes After Ad Removal ================================ */ +.adeclipse-collapsed { + display: none !important; + height: 0 !important; + min-height: 0 !important; + max-height: 0 !important; + margin: 0 !important; + padding: 0 !important; + border: none !important; + overflow: hidden !important; + line-height: 0 !important; + font-size: 0 !important; +} + +.adeclipse-empty-container { + display: none !important; + height: 0 !important; + min-height: 0 !important; + max-height: 0 !important; + margin: 0 !important; + padding: 0 !important; + border: none !important; + overflow: hidden !important; + line-height: 0 !important; + font-size: 0 !important; +} + /* Prevent layout shift from removed ads */ [style*="min-height"]:empty, -[style*="min-height"]:has(> .adeclipse-collapsed) { +[style*="min-height"]:has(> .adeclipse-collapsed), +[style*="min-height"]:has(> .adeclipse-empty-container) { + min-height: 0 !important; + height: auto !important; +} + +[style*="height"]:has(> .adeclipse-collapsed) { + height: auto !important; min-height: 0 !important; } /* Fix gaps left by removed ads */ article > .adeclipse-collapsed, .content > .adeclipse-collapsed, -main > .adeclipse-collapsed { +main > .adeclipse-collapsed, +aside > .adeclipse-collapsed { margin: 0 !important; padding: 0 !important; } diff --git a/src/content/general.js b/src/content/general.js index 3c5ea24..4c468f1 100644 --- a/src/content/general.js +++ b/src/content/general.js @@ -602,30 +602,85 @@ return text; } + const STRUCTURAL_TAGS = new Set([ + 'HTML', 'BODY', 'MAIN', 'ARTICLE', 'HEADER', 'FOOTER', 'NAV' + ]); + /** - * Remove element with animation + * Remove element with animation and collapse empty parent containers */ function removeElement(element) { if (!element || state.elementsRemoved.has(element)) return; state.elementsRemoved.add(element); - // Add removing class for animation element.classList.add('adeclipse-removing'); - // Remove after animation setTimeout(() => { element.classList.add('adeclipse-collapsed'); + element.style.setProperty('min-height', '0', 'important'); + element.style.setProperty('height', '0', 'important'); + element.style.setProperty('margin', '0', 'important'); + element.style.setProperty('padding', '0', 'important'); - // Optionally fully remove from DOM if (CONFIG.mode === 'aggressive') { element.remove(); } + collapseEmptyAncestors(element); + state.adsBlocked++; notifyAdBlocked(); }, 150); } + + function collapseEmptyAncestors(element) { + let parent = element.parentElement; + let depth = 0; + + while (parent && depth < 6) { + if (STRUCTURAL_TAGS.has(parent.tagName)) break; + if (parent.id && /^(content|main|app|root|wrapper|page)$/i.test(parent.id)) break; + + if (isContainerEffectivelyEmpty(parent)) { + parent.classList.add('adeclipse-empty-container'); + parent.style.setProperty('min-height', '0', 'important'); + parent.style.setProperty('height', '0', 'important'); + parent.style.setProperty('margin', '0', 'important'); + parent.style.setProperty('padding', '0', 'important'); + state.elementsRemoved.add(parent); + parent = parent.parentElement; + depth++; + } else { + parent.style.setProperty('min-height', '0', 'important'); + break; + } + } + } + + function isContainerEffectivelyEmpty(el) { + for (const child of el.children) { + if (child.classList.contains('adeclipse-collapsed') || + child.classList.contains('adeclipse-empty-container') || + child.classList.contains('adeclipse-removing')) { + continue; + } + try { + const style = getComputedStyle(child); + if (style.display === 'none' || style.visibility === 'hidden') continue; + const rect = child.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) return false; + } catch (e) { return false; } + } + + let directText = ''; + for (const node of el.childNodes) { + if (node.nodeType === 3) directText += node.textContent; + } + if (directText.trim().length > 0) return false; + + return true; + } /** * Handle lazy-loaded content on scroll diff --git a/src/content/player-mainworld-patch.js b/src/content/player-mainworld-patch.js new file mode 100644 index 0000000..6aa36c8 --- /dev/null +++ b/src/content/player-mainworld-patch.js @@ -0,0 +1,325 @@ +/** + * AdEclipse - Main World Player Patch + * Runs in page context to neutralize common ad loaders before they queue pre-rolls. + */ +(function () { + 'use strict'; + + if (window.__adeclipse_mainworld_patch_loaded) return; + window.__adeclipse_mainworld_patch_loaded = true; + + const AD_URL_PATTERN = /doubleclick|googlesyndication|googleadservices|adservice|\/ads?\/|[?&](ad|ads|adtag|vast|vpaid|preroll|midroll)=|vast|vpaid|preroll|midroll|ima/i; + const MEDIA_URL_PATTERN = /\.(m3u8|mpd|mp4|webm|mkv)(\?|$)/i; + + let lastLikelyContentUrl = ''; + const blockedLog = new Set(); + + function isAdUrl(url) { + return !!url && AD_URL_PATTERN.test(String(url)); + } + + function isLikelyMediaUrl(url) { + return !!url && MEDIA_URL_PATTERN.test(String(url)); + } + + function rememberContentUrl(url) { + if (url && isLikelyMediaUrl(url) && !isAdUrl(url)) { + lastLikelyContentUrl = url; + } + } + + function logOnce(key, msg) { + if (blockedLog.has(key)) return; + blockedLog.add(key); + console.info('[AdEclipse MainPatch]', msg); + } + + function patchFetch() { + if (!window.fetch) return; + const originalFetch = window.fetch.bind(window); + window.fetch = async (...args) => { + const req = args[0]; + const url = typeof req === 'string' ? req : (req?.url || ''); + + if (isLikelyMediaUrl(url) && !isAdUrl(url)) { + rememberContentUrl(url); + } + + if (isAdUrl(url)) { + logOnce(`fetch:${url}`, `blocked ad fetch ${url.slice(0, 140)}`); + if (lastLikelyContentUrl && isLikelyMediaUrl(url)) { + return originalFetch(lastLikelyContentUrl, ...args.slice(1)); + } + return new Response('', { status: 204, statusText: 'No Content' }); + } + + return originalFetch(...args); + }; + } + + function patchXHR() { + if (!window.XMLHttpRequest) return; + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function (method, url, ...rest) { + this.__adeclipse_url = String(url || ''); + if (isLikelyMediaUrl(this.__adeclipse_url) && !isAdUrl(this.__adeclipse_url)) { + rememberContentUrl(this.__adeclipse_url); + } + return originalOpen.call(this, method, url, ...rest); + }; + + XMLHttpRequest.prototype.send = function (...args) { + const url = this.__adeclipse_url || ''; + if (isAdUrl(url)) { + logOnce(`xhr:${url}`, `blocked ad xhr ${url.slice(0, 140)}`); + this.abort(); + return; + } + return originalSend.call(this, ...args); + }; + } + + function patchMediaElementSrc() { + const proto = HTMLMediaElement?.prototype; + if (!proto) return; + const descriptor = Object.getOwnPropertyDescriptor(proto, 'src'); + if (!descriptor?.set || !descriptor?.get) return; + + Object.defineProperty(proto, 'src', { + configurable: true, + enumerable: descriptor.enumerable, + get: function () { + return descriptor.get.call(this); + }, + set: function (value) { + const url = String(value || ''); + if (!url) return descriptor.set.call(this, value); + + if (isAdUrl(url)) { + logOnce(`video-src:${url}`, `blocked media src ${url.slice(0, 140)}`); + if (lastLikelyContentUrl) { + return descriptor.set.call(this, lastLikelyContentUrl); + } + return; + } + + rememberContentUrl(url); + return descriptor.set.call(this, value); + } + }); + } + + function patchAppendChild() { + const originalAppend = Element.prototype.appendChild; + Element.prototype.appendChild = function (child) { + try { + if (child?.tagName === 'SOURCE' || child?.tagName === 'IFRAME') { + const url = child.src || child.getAttribute?.('src') || ''; + if (isAdUrl(url)) { + logOnce(`append:${url}`, `blocked ad node append ${url.slice(0, 140)}`); + return child; + } + rememberContentUrl(url); + } + } catch (e) { + // ignore + } + return originalAppend.call(this, child); + }; + } + + function patchJwPlayer() { + const patchSetup = (jwplayerFn) => { + if (!jwplayerFn || jwplayerFn.__adeclipse_patched) return; + jwplayerFn.__adeclipse_patched = true; + + const wrapPlayer = (player) => { + if (!player || player.__adeclipse_player_patched) return player; + player.__adeclipse_player_patched = true; + + if (typeof player.setup === 'function') { + const originalSetup = player.setup.bind(player); + player.setup = (config = {}) => { + const safeConfig = { ...config }; + delete safeConfig.advertising; + delete safeConfig.adSchedule; + delete safeConfig.adTagUrl; + delete safeConfig.ima; + delete safeConfig.preloadAds; + + if (Array.isArray(safeConfig.sources)) { + safeConfig.sources = safeConfig.sources.filter((s) => !isAdUrl(s?.file || s?.src || '')); + const firstSource = safeConfig.sources[0]?.file || safeConfig.sources[0]?.src; + rememberContentUrl(firstSource || ''); + } + + return originalSetup(safeConfig); + }; + } + + return player; + }; + + const wrapped = function (...args) { + const player = jwplayerFn(...args); + return wrapPlayer(player); + }; + + Object.keys(jwplayerFn).forEach((k) => { + try { + wrapped[k] = jwplayerFn[k]; + } catch (e) { + // ignore readonly props + } + }); + + window.jwplayer = wrapped; + }; + + if (window.jwplayer) patchSetup(window.jwplayer); + + try { + let _jwplayer = window.jwplayer; + Object.defineProperty(window, 'jwplayer', { + configurable: true, + get() { + return _jwplayer; + }, + set(v) { + _jwplayer = v; + patchSetup(v); + } + }); + } catch (e) { + // property may be non-configurable + } + } + + function patchVideoJs() { + const patch = (videojs) => { + if (!videojs || videojs.__adeclipse_patched) return; + videojs.__adeclipse_patched = true; + const original = videojs; + + const wrapped = function (...args) { + const player = original(...args); + if (player && !player.__adeclipse_player_patched) { + player.__adeclipse_player_patched = true; + // disable common ad plugins + player.ima = function () { return player; }; + player.vastClient = function () { return player; }; + player.adScheduler = function () { return player; }; + player.ads = function () { return player; }; + } + return player; + }; + + Object.keys(original).forEach((k) => { + try { + wrapped[k] = original[k]; + } catch (e) { + // ignore readonly props + } + }); + + window.videojs = wrapped; + }; + + if (window.videojs) patch(window.videojs); + } + + function patchHls() { + const patch = (Hls) => { + if (!Hls || Hls.__adeclipse_patched) return; + Hls.__adeclipse_patched = true; + const proto = Hls.prototype; + if (!proto) return; + + const originalLoadSource = proto.loadSource; + if (typeof originalLoadSource === 'function') { + proto.loadSource = function (url) { + const src = String(url || ''); + if (isAdUrl(src)) { + logOnce(`hls:${src}`, `blocked hls ad source ${src.slice(0, 140)}`); + if (lastLikelyContentUrl) { + return originalLoadSource.call(this, lastLikelyContentUrl); + } + return; + } + rememberContentUrl(src); + return originalLoadSource.call(this, url); + }; + } + }; + + if (window.Hls) patch(window.Hls); + + try { + let _Hls = window.Hls; + Object.defineProperty(window, 'Hls', { + configurable: true, + get() { + return _Hls; + }, + set(v) { + _Hls = v; + patch(v); + } + }); + } catch (e) { + // ignore + } + } + + function patchShaka() { + if (!window.shaka?.Player?.prototype) return; + const proto = window.shaka.Player.prototype; + if (proto.__adeclipse_patched) return; + proto.__adeclipse_patched = true; + + const originalLoad = proto.load; + if (typeof originalLoad === 'function') { + proto.load = function (url, ...rest) { + const src = String(url || ''); + if (isAdUrl(src)) { + logOnce(`shaka:${src}`, `blocked shaka ad source ${src.slice(0, 140)}`); + if (lastLikelyContentUrl) { + return originalLoad.call(this, lastLikelyContentUrl, ...rest); + } + return Promise.resolve(); + } + rememberContentUrl(src); + return originalLoad.call(this, url, ...rest); + }; + } + } + + function removeVisibleAdOverlays() { + const selectors = [ + '[class*="ad-overlay"]', '[id*="ad-overlay"]', + '[class*="preroll"]', '[class*="ad-break"]', + '[class*="ima-ad"]', '[class*="vast"]', '[class*="vpaid"]' + ]; + document.querySelectorAll(selectors.join(',')).forEach((el) => { + el.style.setProperty('display', 'none', 'important'); + el.style.setProperty('opacity', '0', 'important'); + el.style.setProperty('pointer-events', 'none', 'important'); + }); + } + + function bootstrap() { + patchFetch(); + patchXHR(); + patchMediaElementSrc(); + patchAppendChild(); + patchJwPlayer(); + patchVideoJs(); + patchHls(); + patchShaka(); + setInterval(removeVisibleAdOverlays, 700); + } + + bootstrap(); +})(); diff --git a/src/content/video-ad-interceptor.js b/src/content/video-ad-interceptor.js new file mode 100644 index 0000000..4da66df --- /dev/null +++ b/src/content/video-ad-interceptor.js @@ -0,0 +1,475 @@ +/** + * AdEclipse - Video Ad Interceptor + * Detects pre-roll/interstitial video ad players on streaming/movie sites + * and auto-skips, fast-forwards, or removes them to reach actual content. + */ + +(function () { + 'use strict'; + + if (window.__adeclipse_video_interceptor_loaded) return; + window.__adeclipse_video_interceptor_loaded = true; + + const AD_VIDEO_DOMAINS = [ + 'doubleclick.net', 'googlesyndication.com', 'googleadservices.com', + 'google-analytics.com', 'adservice.google.com', 'pagead2.googlesyndication.com', + 'imasdk.googleapis.com', 'youtube.com/api/stats/ads', + 'amazon-adsystem.com', 'ads.yahoo.com', 'ads.aol.com', + 'adsrvr.org', 'adform.net', 'serving-sys.com', + 'bidswitch.net', 'casalemedia.com', 'pubmatic.com', + 'springserve.com', 'spotxchange.com', 'videoadex.com', + 'innovid.com', 'extremereach.io', 'flashtalking.com', + 'cdn.adsafeprotected.com', 'moatads.com' + ]; + + const AD_CONTAINER_SELECTORS = [ + '.ima-ad-container', '.ad-container', '.video-ad', '.preroll-ad', + '.ad-playing', '.vjs-ad-playing', '.ad-overlay', '[class*="ad-player"]', + '[class*="adPlayer"]', '[class*="preroll"]', '[class*="midroll"]', + '[class*="vastPlayer"]', '[id*="player_ad"]', '[id*="ad-player"]', + '.ad-break', '[class*="ad-break"]', '.videoAdPlayer', + '[class*="vast-"]', '[class*="vpaid-"]' + ]; + + const SKIP_BUTTON_SELECTORS = [ + '[class*="skip"]', '[id*="skip"]', '[class*="Skip"]', + '[class*="close-ad"]', '[class*="closeAd"]', '[class*="ad-close"]', + '[aria-label*="skip" i]', '[aria-label*="close" i]', + '[title*="skip" i]', '[title*="close" i]', + 'button[class*="dismiss"]', '.ad-skip-button', + '.skip-ad', '.skipBtn', '.skip-btn', '.skip_button', + '[data-testid*="skip"]', '[class*="countdown-skip"]' + ]; + + const COUNTDOWN_SELECTORS = [ + '[class*="countdown"]', '[class*="timer"]', '[class*="remaining"]', + '[class*="ad-timer"]', '[class*="ad-count"]' + ]; + + let config = { enabled: false }; + let observer = null; + let pollInterval = null; + let processedVideos = new WeakSet(); + let interceptedOverlays = new WeakSet(); + let llmCheckedVideos = new WeakSet(); + let originalFetch = null; + let originalXHROpen = null; + let originalXHRSend = null; + let playerSwitchCooldownUntil = 0; + + async function initialize() { + try { + const response = await chrome.runtime.sendMessage({ type: 'AI_GET_CONFIG' }); + if (!response || !response.enabled) return; + config = { ...config, ...response, enabled: true }; + + interceptAdNetworkRequests(); + startVideoMonitor(); + setupDOMObserver(); + } catch (e) { + // Extension context invalid + } + } + + function startVideoMonitor() { + pollInterval = setInterval(scanForVideoAds, 800); + scanForVideoAds(); + } + + function setupDOMObserver() { + observer = new MutationObserver((mutations) => { + let needsScan = false; + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType !== 1) continue; + if (node.tagName === 'VIDEO' || node.querySelector?.('video')) { + needsScan = true; + break; + } + if (matchesAny(node, AD_CONTAINER_SELECTORS)) { + needsScan = true; + break; + } + } + if (needsScan) break; + } + if (needsScan) { + setTimeout(scanForVideoAds, 100); + } + }); + + observer.observe(document.body || document.documentElement, { + childList: true, + subtree: true + }); + } + + function scanForVideoAds() { + autoClickSkipButtons(); + handleLocalizedAdMarkers(); + handleAdOverlays(); + handleVideoElements(); + } + + function autoClickSkipButtons() { + const selectorString = SKIP_BUTTON_SELECTORS.join(', '); + try { + const buttons = document.querySelectorAll(selectorString); + for (const btn of buttons) { + if (!isVisible(btn)) continue; + const text = (btn.textContent || btn.innerText || '').toLowerCase(); + if ( + /skip|close|dismiss|fermer|schlie|пропуст|закры|close ad/i.test(text) || + /skip|close|пропуст|закры/i.test(btn.getAttribute('aria-label') || '') || + /skip|close|пропуст|закры/i.test(btn.getAttribute('title') || '') + ) { + btn.click(); + btn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + } + } + } catch (e) { /* ignore */ } + } + + function handleLocalizedAdMarkers() { + const bodyText = (document.body?.innerText || '').toLowerCase(); + const hasAdMarker = + /реклама[:\s]/i.test(bodyText) || + /advertisement|ad\s*[:\/]\s*\d|ad\s*\d+\s*\/\s*\d+/i.test(bodyText) || + /осталось[:\s]\d+/i.test(bodyText); + + if (!hasAdMarker) return; + + // Force any currently playing short video to end. + const videos = document.querySelectorAll('video'); + for (const video of videos) { + if (!video || video.paused) continue; + try { + if (!isFinite(video.duration) || video.duration <= 0) continue; + if (video.duration <= 60 || isAdVideo(video)) { + speedUpAdVideo(video); + } + } catch (e) { /* ignore */ } + } + + // If a site offers multiple mirror players, rotate when ad marker is active. + trySwitchToAlternativePlayer(); + } + + function trySwitchToAlternativePlayer() { + const now = Date.now(); + if (now < playerSwitchCooldownUntil) return; + + const tabCandidates = Array.from(document.querySelectorAll('button, a, li, div')).filter((el) => { + const text = (el.textContent || '').trim().toLowerCase(); + if (!text) return false; + if (!/плеер|player/i.test(text)) return false; + if (text.includes('трейлер') || text.includes('trailer')) return false; + return isVisible(el); + }); + + if (tabCandidates.length <= 1) return; + + // Pick next visible inactive tab. + const currentIndex = tabCandidates.findIndex((el) => + el.classList.contains('active') || el.getAttribute('aria-selected') === 'true' + ); + const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % tabCandidates.length : 0; + const nextTab = tabCandidates[nextIndex]; + if (!nextTab) return; + + nextTab.click(); + nextTab.dispatchEvent(new MouseEvent('click', { bubbles: true })); + playerSwitchCooldownUntil = now + 6000; + } + + function handleAdOverlays() { + const selectorString = AD_CONTAINER_SELECTORS.join(', '); + try { + const overlays = document.querySelectorAll(selectorString); + for (const overlay of overlays) { + if (interceptedOverlays.has(overlay)) continue; + if (!isVisible(overlay)) continue; + + interceptedOverlays.add(overlay); + + const videos = overlay.querySelectorAll('video'); + for (const video of videos) { + speedUpAdVideo(video); + } + + const iframes = overlay.querySelectorAll('iframe'); + for (const iframe of iframes) { + const src = (iframe.src || '').toLowerCase(); + if (AD_VIDEO_DOMAINS.some(d => src.includes(d))) { + iframe.src = 'about:blank'; + iframe.style.setProperty('display', 'none', 'important'); + } + } + } + } catch (e) { /* ignore */ } + } + + function handleVideoElements() { + const videos = document.querySelectorAll('video'); + + for (const video of videos) { + if (processedVideos.has(video)) continue; + + if (isAdVideo(video)) { + processedVideos.add(video); + speedUpAdVideo(video); + attachAdVideoListeners(video); + continue; + } + + if (!llmCheckedVideos.has(video) && isLikelyQueuedAd(video)) { + llmCheckedVideos.add(video); + analyzeWithLLM(video); + } + + if (!video.__adeclipse_checked) { + video.__adeclipse_checked = true; + video.addEventListener('playing', function handler() { + if (isAdVideo(video)) { + processedVideos.add(video); + speedUpAdVideo(video); + attachAdVideoListeners(video); + } + }); + } + } + } + + function isAdVideo(video) { + const src = (video.src || video.currentSrc || '').toLowerCase(); + if (AD_VIDEO_DOMAINS.some(d => src.includes(d))) return true; + + const parent = video.closest(AD_CONTAINER_SELECTORS.join(', ')); + if (parent) return true; + + const container = video.parentElement; + if (container) { + const attrs = `${container.id || ''} ${container.className || ''}`.toLowerCase(); + if (/\bad[-_]?(player|container|wrapper|overlay|break)\b/i.test(attrs)) return true; + if (/\bvast\b|\bvpaid\b|\bima[-_]?\b|\bpreroll\b/i.test(attrs)) return true; + } + + if (video.duration > 0 && video.duration <= 35) { + const pageVideos = document.querySelectorAll('video'); + if (pageVideos.length > 1) { + for (const other of pageVideos) { + if (other !== video && other.duration > video.duration * 3) { + return true; + } + } + } + } + + const sources = video.querySelectorAll('source'); + for (const source of sources) { + const sSrc = (source.src || '').toLowerCase(); + if (AD_VIDEO_DOMAINS.some(d => sSrc.includes(d))) return true; + } + + return false; + } + + function isLikelyQueuedAd(video) { + const src = (video.src || video.currentSrc || '').toLowerCase(); + if (!src) return false; + + const parentAttrs = `${video.parentElement?.id || ''} ${video.parentElement?.className || ''}`.toLowerCase(); + if (/\bplayer\b/.test(parentAttrs) && /\bad\b|\bvast\b|\bvpaid\b|\bpromo\b/.test(parentAttrs)) { + return true; + } + + if (video.duration > 0 && video.duration <= 45) { + return true; + } + + return /ad[s]?|vast|vpaid|preroll|doubleclick|googlesyndication|promo/.test(src); + } + + function speedUpAdVideo(video) { + try { + video.muted = true; + video.playbackRate = 16; + video.volume = 0; + + Object.defineProperty(video, 'playbackRate', { + get() { return 16; }, + set() { return 16; }, + configurable: true + }); + + if (video.duration > 0 && isFinite(video.duration)) { + video.currentTime = video.duration - 0.1; + } + + forceExitAdMode(video); + } catch (e) { /* some properties may be locked */ } + } + + function attachAdVideoListeners(video) { + const trySkipToEnd = () => { + try { + if (video.duration > 0 && isFinite(video.duration)) { + video.currentTime = video.duration - 0.1; + } + video.playbackRate = 16; + video.muted = true; + forceExitAdMode(video); + } catch (e) { /* ignore */ } + }; + + video.addEventListener('loadedmetadata', trySkipToEnd); + video.addEventListener('durationchange', trySkipToEnd); + video.addEventListener('canplay', trySkipToEnd); + + video.addEventListener('ended', () => { + autoClickSkipButtons(); + const adContainer = video.closest(AD_CONTAINER_SELECTORS.join(', ')); + if (adContainer) { + adContainer.style.setProperty('display', 'none', 'important'); + } + forceExitAdMode(video); + }); + + if (video.readyState >= 1) { + trySkipToEnd(); + } + } + + function analyzeWithLLM(videoElement) { + const container = videoElement.closest('div, section, article') || videoElement.parentElement; + if (!container) return; + + const descriptor = { + tag: container.tagName.toLowerCase(), + classes: (container.className || '').toString().split(/\s+/).filter(Boolean).slice(0, 10), + id: container.id || undefined, + videoSrc: (videoElement.src || videoElement.currentSrc || '').slice(0, 100), + videoDuration: videoElement.duration || 0, + childTags: Array.from(container.children).slice(0, 10).map(c => c.tagName.toLowerCase()), + hasSkipButton: !!container.querySelector(SKIP_BUTTON_SELECTORS.join(', ')), + hasCountdown: !!container.querySelector(COUNTDOWN_SELECTORS.join(', ')), + text: (container.textContent || '').trim().slice(0, 200) + }; + + chrome.runtime.sendMessage({ + type: 'AI_SCAN_ELEMENTS', + data: { + elements: [{ + id: 'video_ad_check', + ...descriptor, + width: videoElement.clientWidth, + height: videoElement.clientHeight, + position: 'overlay', + hasVideo: true, + hasIframe: !!container.querySelector('iframe'), + linkCount: container.querySelectorAll('a').length, + externalLinkCount: 0 + }], + domain: window.location.hostname + } + }).then(response => { + if (response?.results?.[0]?.isAd) { + speedUpAdVideo(videoElement); + forceExitAdMode(videoElement); + } + }).catch(() => {}); + } + + function forceExitAdMode(video) { + try { + const playerRoot = video.closest('[class*="player"], [id*="player"], .jwplayer, .vjs-tech, .video-js'); + if (!playerRoot) return; + + const classes = [ + 'ad-playing', 'ads-playing', 'vjs-ad-playing', 'ima-ad-playing', + 'ad-showing', 'ad-container-visible', 'ad-break-active' + ]; + for (const cls of classes) { + playerRoot.classList.remove(cls); + } + + for (const selector of AD_CONTAINER_SELECTORS) { + playerRoot.querySelectorAll(selector).forEach((el) => { + el.style.setProperty('display', 'none', 'important'); + el.style.setProperty('pointer-events', 'none', 'important'); + el.style.setProperty('opacity', '0', 'important'); + }); + } + + const countdownNodes = playerRoot.querySelectorAll(COUNTDOWN_SELECTORS.join(', ')); + countdownNodes.forEach((node) => node.remove()); + } catch (e) { /* ignore */ } + } + + function interceptAdNetworkRequests() { + try { + if (!window.fetch || originalFetch) return; + originalFetch = window.fetch.bind(window); + window.fetch = async (...args) => { + const input = args[0]; + const url = typeof input === 'string' ? input : (input?.url || ''); + if (shouldBlockAdRequest(url)) { + return new Response('', { status: 204, statusText: 'No Content' }); + } + return originalFetch(...args); + }; + } catch (e) { /* ignore */ } + + try { + if (!window.XMLHttpRequest || originalXHROpen) return; + originalXHROpen = XMLHttpRequest.prototype.open; + originalXHRSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function (method, url, ...rest) { + this.__adeclipse_request_url = typeof url === 'string' ? url : ''; + return originalXHROpen.call(this, method, url, ...rest); + }; + + XMLHttpRequest.prototype.send = function (...args) { + if (shouldBlockAdRequest(this.__adeclipse_request_url || '')) { + this.abort(); + return; + } + return originalXHRSend.call(this, ...args); + }; + } catch (e) { /* ignore */ } + } + + function shouldBlockAdRequest(url) { + if (!url) return false; + const lower = url.toLowerCase(); + + if (AD_VIDEO_DOMAINS.some((d) => lower.includes(d))) return true; + if (/\/(ads?|adserver|vast|vpaid|preroll|midroll|adbreak)\b/.test(lower)) return true; + if (/[?&](ad|ads|ad_unit|adunit|adtag|vast|vpaid|preroll)=/.test(lower)) return true; + + return false; + } + + function matchesAny(element, selectors) { + try { + return element.matches && element.matches(selectors.join(', ')); + } catch (e) { + return false; + } + } + + function isVisible(el) { + if (!el) return false; + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return false; + const style = getComputedStyle(el); + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initialize); + } else { + initialize(); + } +})(); diff --git a/src/content/youtube-mainworld.js b/src/content/youtube-mainworld.js index 97fcb30..2a8e948 100644 --- a/src/content/youtube-mainworld.js +++ b/src/content/youtube-mainworld.js @@ -67,6 +67,51 @@ 'searchPyv' ]; + const DIAGNOSTIC_MESSAGE_TYPE = 'ADECLIPSE_YOUTUBE_DIAGNOSTIC'; + const diagnosticTimestamps = new Map(); + + const getPageType = () => { + const path = window.location.pathname || ''; + + if (path.startsWith('/watch')) return 'watch'; + if (path.startsWith('/shorts')) return 'shorts'; + if (path.startsWith('/results')) return 'search'; + if (path.startsWith('/feed')) return 'feed'; + return 'browse'; + }; + + const sanitizeUrl = (url) => { + try { + const parsed = new URL(url, window.location.origin); + return parsed.origin + parsed.pathname; + } catch (_) { + return String(url || '').slice(0, 180); + } + }; + + const reportDiagnostic = (type, payload = {}) => { + try { + const dedupeKey = [type, payload.signal || '', payload.dedupeKey || ''].join('|'); + const now = Date.now(); + const previous = diagnosticTimestamps.get(dedupeKey) || 0; + + if (now - previous < 15000) return; + diagnosticTimestamps.set(dedupeKey, now); + + window.postMessage({ + type: DIAGNOSTIC_MESSAGE_TYPE, + payload: { + source: 'youtube-mainworld', + type, + signal: payload.signal || '', + pageType: getPageType(), + url: window.location.href, + details: payload.details || {} + } + }, '*'); + } catch (_) { } + }; + const isTargetYoutubeiRequest = (url) => ( url.includes('/youtubei/v1/player') || url.includes('/youtubei/v1/next') || @@ -75,19 +120,57 @@ url.includes('/youtubei/v1/ad_break') ); - const cleanseObject = (obj, seen = new WeakSet()) => { + const isUnknownAdLikeKey = (key) => { + const lower = String(key || '').toLowerCase(); + if (!lower) return false; + if (!(lower.includes('ad') || lower.includes('promoted') || lower.includes('sponsor'))) return false; + + return !AD_KEYS.some((knownKey) => knownKey.toLowerCase() === lower); + }; + + const getValueType = (value) => { + if (Array.isArray(value)) return 'array'; + if (value === null) return 'null'; + return typeof value; + }; + + const recordUnknownKey = (diagnostics, key, path, value) => { + if (!diagnostics || diagnostics.unknownKeys.length >= 8) return; + if (!isUnknownAdLikeKey(key)) return; + + const keyPath = path.concat(key).slice(-4).join('.'); + if (diagnostics.unknownKeySet.has(keyPath)) return; + + diagnostics.unknownKeySet.add(keyPath); + diagnostics.unknownKeys.push({ + key, + path: path.slice(-3).join('.'), + valueType: getValueType(value) + }); + }; + + const cleanseObject = (obj, diagnostics, seen = new WeakSet(), path = []) => { if (!obj || typeof obj !== 'object') return obj; if (seen.has(obj)) return obj; seen.add(obj); if (Array.isArray(obj)) { - for (const item of obj) cleanseObject(item, seen); + for (const item of obj) cleanseObject(item, diagnostics, seen, path); return obj; } + Object.entries(obj).forEach(([key, value]) => { + recordUnknownKey(diagnostics, key, path, value); + }); + // Delete known ad keys for (const key of AD_KEYS) { - if (key in obj) delete obj[key]; + if (key in obj) { + delete obj[key]; + if (diagnostics && !diagnostics.removedKeys.includes(key)) { + diagnostics.removedKeys.push(key); + } + } } if (Array.isArray(obj.adPlacements)) obj.adPlacements = []; @@ -116,8 +199,32 @@ } } - for (const value of Object.values(obj)) { - cleanseObject(value, seen); + Object.entries(obj).forEach(([key, value]) => { + cleanseObject(value, diagnostics, seen, path.concat(key)); + }); + + return obj; + }; + + const sanitizePayload = (obj, context) => { + const diagnostics = { + removedKeys: [], + unknownKeys: [], + unknownKeySet: new Set() + }; + + cleanseObject(obj, diagnostics); + + if (diagnostics.unknownKeys.length > 0) { + reportDiagnostic('sanitizer-unknown-keys', { + signal: context.signal, + dedupeKey: context.signal + ':' + diagnostics.unknownKeys.map((entry) => entry.key).join(','), + details: { + requestUrl: sanitizeUrl(context.requestUrl || window.location.href), + removedKeys: diagnostics.removedKeys.slice(0, 10), + unknownKeys: diagnostics.unknownKeys + } + }); } return obj; @@ -139,12 +246,18 @@ const patchInitialResponse = () => { try { if (window.ytInitialPlayerResponse) { - cleanseObject(window.ytInitialPlayerResponse); + sanitizePayload(window.ytInitialPlayerResponse, { + signal: 'initial-player-response', + requestUrl: window.location.href + }); } } catch (_) { } try { if (window.ytInitialData) { - cleanseObject(window.ytInitialData); + sanitizePayload(window.ytInitialData, { + signal: 'initial-data', + requestUrl: window.location.href + }); } } catch (_) { } }; @@ -158,7 +271,10 @@ return current; }, set(value) { - current = cleanseObject(value); + current = sanitizePayload(value, { + signal: 'player-response-setter', + requestUrl: window.location.href + }); } }); } catch (_) { } @@ -173,7 +289,10 @@ return currentData; }, set(value) { - currentData = cleanseObject(value); + currentData = sanitizePayload(value, { + signal: 'initial-data-setter', + requestUrl: window.location.href + }); } }); } catch (_) { } @@ -191,7 +310,10 @@ if (!contentType.includes('application/json')) return response; const json = await response.clone().json(); - cleanseObject(json); + sanitizePayload(json, { + signal: 'fetch:' + url.split('/').pop(), + requestUrl: url + }); return buildJsonResponse(json, response); } catch (_) { return response; @@ -215,7 +337,10 @@ try { if (typeof this.responseText !== 'string' || !this.responseText) return; const parsed = JSON.parse(this.responseText); - cleanseObject(parsed); + sanitizePayload(parsed, { + signal: 'xhr:' + this.__adeclipseUrl.split('/').pop(), + requestUrl: this.__adeclipseUrl + }); const serialized = JSON.stringify(parsed); try { diff --git a/src/content/youtube-utils.js b/src/content/youtube-utils.js new file mode 100644 index 0000000..edbf080 --- /dev/null +++ b/src/content/youtube-utils.js @@ -0,0 +1,112 @@ +(function (root, factory) { + var exports = factory(); + + if (typeof module !== 'undefined' && module.exports) { + module.exports = exports; + } + + root.__ADECLIPSE_YT_UTILS__ = exports; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + function isFiniteNumber(value) { + return typeof value === 'number' && Number.isFinite(value); + } + + function clampPlaybackTarget(targetTime, duration) { + if (!isFiniteNumber(targetTime) || targetTime < 0) return 0; + + if (isFiniteNumber(duration) && duration > 1) { + return Math.min(targetTime, Math.max(duration - 0.25, 0)); + } + + return targetTime; + } + + function getResumeTargetTime(candidates) { + var best = 0; + + if (!Array.isArray(candidates)) return best; + + candidates.forEach(function (candidate) { + if (isFiniteNumber(candidate) && candidate > best) { + best = candidate; + } + }); + + return best; + } + + function shouldRestorePlaybackPosition(targetTime, currentTime, duration) { + if (!isFiniteNumber(currentTime) || currentTime < 0) return false; + + var safeTarget = clampPlaybackTarget(targetTime, duration); + if (safeTarget < 1) return false; + + var delta = currentTime - safeTarget; + if (Math.abs(delta) <= 1.5) return false; + + if (currentTime <= Math.min(3, safeTarget * 0.25)) { + return true; + } + + if (delta > 15) { + return true; + } + + return false; + } + + function extractScrollTargetY(argsLike) { + if (!argsLike || typeof argsLike.length !== 'number' || argsLike.length === 0) { + return null; + } + + var first = argsLike[0]; + if (first && typeof first === 'object') { + return isFiniteNumber(first.top) ? first.top : null; + } + + return isFiniteNumber(argsLike[1]) ? argsLike[1] : null; + } + + function getScrollDirectionFromPositions(previousY, nextY) { + if (!isFiniteNumber(previousY) || !isFiniteNumber(nextY)) return 0; + + if (nextY > previousY + 2) return 1; + if (nextY < previousY - 2) return -1; + return 0; + } + + function shouldBlockProgrammaticScroll(targetY, protectedY, scrollDirection, activeUntil, now) { + if (!isFiniteNumber(targetY) || !isFiniteNumber(protectedY)) return false; + if (!scrollDirection || now > activeUntil) return false; + + if (scrollDirection > 0) { + return targetY < protectedY - 120; + } + + return targetY > protectedY + 120; + } + + function shouldRecoverScrollPosition(currentY, protectedY, scrollDirection, activeUntil, now) { + if (!isFiniteNumber(currentY) || !isFiniteNumber(protectedY)) return false; + if (!scrollDirection || now > activeUntil) return false; + + if (scrollDirection > 0) { + return currentY < protectedY - 140; + } + + return currentY > protectedY + 140; + } + + return { + clampPlaybackTarget: clampPlaybackTarget, + extractScrollTargetY: extractScrollTargetY, + getResumeTargetTime: getResumeTargetTime, + getScrollDirectionFromPositions: getScrollDirectionFromPositions, + shouldBlockProgrammaticScroll: shouldBlockProgrammaticScroll, + shouldRecoverScrollPosition: shouldRecoverScrollPosition, + shouldRestorePlaybackPosition: shouldRestorePlaybackPosition + }; +}); \ No newline at end of file diff --git a/src/content/youtube.js b/src/content/youtube.js index 29e7466..58f8453 100644 --- a/src/content/youtube.js +++ b/src/content/youtube.js @@ -28,6 +28,10 @@ if (window.__ADECLIPSE_YT_LOADED__) return; window.__ADECLIPSE_YT_LOADED__ = true; + var ytUtils = window.__ADECLIPSE_YT_UTILS__ || {}; + var DIAGNOSTIC_MESSAGE_TYPE = 'ADECLIPSE_YOUTUBE_DIAGNOSTIC'; + var recentDiagnostics = Object.create(null); + /* ── Enabled gate ──────────────────────────────────────────── */ /* Ask the background whether the extension is enabled for this * * site. If disabled (global OFF or site-whitelisted) we bail * @@ -92,6 +96,21 @@ '#player-overlay\\:0,' + '#player-overlay-layout\\:0'; + var PLAYER_PROMO_SURFACE_SEL = + '.ytp-ad-player-overlay,' + + '.ytp-ad-player-overlay-layout,' + + '.ytp-ad-player-overlay-instream-info,' + + '.ytp-ad-player-overlay-skip-or-preview,' + + '.ytp-ad-action-interstitial-slot,' + + '.ytp-ad-action-interstitial-background-container,' + + '.ytp-ad-message-container,' + + '.ytp-ad-button,' + + '.ytp-ad-button-icon,' + + '.ytp-visit-advertiser-link,' + + '.ytp-ad-visit-advertiser-button,' + + '.ytp-paid-content-overlay,' + + '#player-ads'; + var STATIC_AD_SEL = '#masthead-ad,' + 'ytd-display-ad-renderer,' + @@ -103,7 +122,11 @@ 'ytd-banner-promo-renderer,' + 'ytd-video-masthead-ad-v3-renderer,' + 'ytd-primetime-promo-renderer,' + + 'ytd-promoted-video-renderer,' + 'ytd-player-legacy-desktop-watch-ads-renderer,' + + '.ytd-player-legacy-desktop-watch-ads-renderer,' + + '.ytd-action-companion-ad-renderer,' + + '.ytd-companion-slot-renderer,' + 'ytd-rich-item-renderer:has(ytd-ad-slot-renderer),' + 'ytd-rich-section-renderer:has(ytd-ad-slot-renderer)'; @@ -128,6 +151,8 @@ var adSeekedToEnd = false; var adEndTimestamp = 0; var wasInAdMode = false; + var realVideoStartTime = 0; // Track where the real video started + var postAdRecoveryToken = 0; /* ── Authoritative ad check ──────────────────────────────────── */ @@ -138,6 +163,116 @@ ); } + function playerHasPromotedSurface(player) { + if (!player) return false; + + return Array.prototype.some.call( + player.querySelectorAll(SKIP_BTN_SEL + ',' + PLAYER_PROMO_SURFACE_SEL), + function (node) { + return isActionablePromoNode(node); + } + ); + } + + function playerNeedsIntervention(player) { + return playerInAdMode(player) || playerHasPromotedSurface(player); + } + + function isActionablePromoNode(node) { + if (!node || !node.isConnected) return false; + if (node.hidden) return false; + + var ariaHidden = node.getAttribute && node.getAttribute('aria-hidden'); + if (ariaHidden === 'true') return false; + + var style = node.style; + if (!style) return true; + + if (style.display === 'none') return false; + if (style.visibility === 'hidden') return false; + if (style.pointerEvents === 'none') return false; + + return true; + } + + function getPageType() { + var path = window.location.pathname || ''; + + if (path.indexOf('/watch') === 0) return 'watch'; + if (path.indexOf('/shorts') === 0) return 'shorts'; + if (path.indexOf('/results') === 0) return 'search'; + if (path.indexOf('/feed') === 0) return 'feed'; + return 'browse'; + } + + function summarizeElement(el) { + if (!el || !el.tagName) return null; + + return { + tag: el.tagName.toLowerCase(), + id: el.id || '', + classes: Array.prototype.slice.call(el.classList || [], 0, 6) + }; + } + + function reportDiagnostic(type, payload) { + try { + var details = payload && payload.details ? payload.details : {}; + var signal = payload && payload.signal ? payload.signal : ''; + var key = [type, signal, JSON.stringify(details).slice(0, 120)].join('|'); + var now = Date.now(); + + if (recentDiagnostics[key] && now - recentDiagnostics[key] < 15000) { + return; + } + + recentDiagnostics[key] = now; + + chrome.runtime.sendMessage({ + type: 'YOUTUBE_DIAGNOSTIC_EVENT', + data: { + source: payload && payload.source ? payload.source : 'youtube-isolated', + type: type, + signal: signal, + pageType: getPageType(), + url: window.location.href, + details: details, + request: payload && payload.request ? payload.request : null + } + }, function () { + void chrome.runtime.lastError; + }); + } catch (_) {} + } + + function attachMainWorldDiagnosticsBridge() { + window.addEventListener('message', function (event) { + if (event.source !== window || !event.data || event.data.type !== DIAGNOSTIC_MESSAGE_TYPE) { + return; + } + + reportDiagnostic(event.data.payload.type || 'mainworld-event', { + source: event.data.payload.source || 'youtube-mainworld', + signal: event.data.payload.signal || '', + details: event.data.payload.details || {} + }); + }, true); + } + + function reportPromotedPlayerSurface(player) { + var promoSurface = player.querySelector(PLAYER_PROMO_SURFACE_SEL); + var skipButton = player.querySelector(SKIP_BTN_SEL); + + reportDiagnostic('player-surface', { + signal: 'promoted-player-surface', + details: { + hasSkipButton: Boolean(skipButton), + surface: summarizeElement(promoSurface || skipButton), + playerClasses: Array.prototype.slice.call(player.classList || [], 0, 10) + } + }); + } + /* ── URL timestamp helper ──────────────────────────────────── */ function getUrlStartTime() { @@ -158,6 +293,100 @@ return 0; } + function getResumeTargetTime(player, video) { + var candidates = []; + + if (video && Number.isFinite(video.currentTime)) { + candidates.push(video.currentTime); + } + + if (player && typeof player.getCurrentTime === 'function') { + try { + candidates.push(player.getCurrentTime()); + } catch (_) {} + } + + candidates.push(getUrlStartTime()); + + if (typeof ytUtils.getResumeTargetTime === 'function') { + return ytUtils.getResumeTargetTime(candidates); + } + + return candidates.reduce(function (best, candidate) { + return Number.isFinite(candidate) && candidate > best ? candidate : best; + }, 0); + } + + function clampPlaybackTarget(targetTime, duration) { + if (typeof ytUtils.clampPlaybackTarget === 'function') { + return ytUtils.clampPlaybackTarget(targetTime, duration); + } + + if (!Number.isFinite(targetTime) || targetTime < 0) return 0; + if (Number.isFinite(duration) && duration > 1) { + return Math.min(targetTime, Math.max(duration - 0.25, 0)); + } + return targetTime; + } + + function shouldRestorePlaybackPosition(targetTime, currentTime, duration) { + if (typeof ytUtils.shouldRestorePlaybackPosition === 'function') { + return ytUtils.shouldRestorePlaybackPosition(targetTime, currentTime, duration); + } + + if (!Number.isFinite(currentTime) || currentTime < 0) return false; + var safeTarget = clampPlaybackTarget(targetTime, duration); + if (safeTarget < 1) return false; + if (Math.abs(currentTime - safeTarget) <= 1.5) return false; + return currentTime <= Math.min(3, safeTarget * 0.25) || currentTime > safeTarget + 15; + } + + function ensurePlayback(player, video) { + if (!video || playerInAdMode(player)) return; + if (!video.paused || video.readyState < 2) return; + + try { + var playPromise = video.play(); + if (playPromise !== undefined) { + playPromise.catch(function () { + var playBtn = player.querySelector('.ytp-play-button, button[aria-label="Play"]'); + if (playBtn) { + try { playBtn.click(); } catch (_) {} + } + }); + } + } catch (_) {} + } + + function queuePostAdRecovery(player, video) { + var recoveryToken = ++postAdRecoveryToken; + var restoreTargetTime = getResumeTargetTime(player, video); + var attemptDelays = [0, 120, 350, 800, 1400]; + + attemptDelays.forEach(function (delay, index) { + setTimeout(function () { + if (postAdRecoveryToken !== recoveryToken) return; + if (!video || playerInAdMode(player)) return; + + if (video.playbackRate !== 1) { + video.playbackRate = 1; + } + + if (shouldRestorePlaybackPosition(restoreTargetTime, video.currentTime, video.duration)) { + try { + video.currentTime = clampPlaybackTarget(restoreTargetTime, video.duration); + } catch (_) {} + } + + ensurePlayback(player, video); + + if (index === attemptDelays.length - 1) { + wasInAdMode = false; + } + }, delay); + }); + } + /* ── Micro-actions ───────────────────────────────────────────── */ function clickSkipButtons() { @@ -172,6 +401,28 @@ }); } + function hidePlayerPromoSurfaces(player) { + if (!player) return; + + player.querySelectorAll(PLAYER_PROMO_SURFACE_SEL).forEach(function (el) { + try { + el.style.setProperty('display', 'none', 'important'); + el.style.setProperty('visibility', 'hidden', 'important'); + el.style.setProperty('pointer-events', 'none', 'important'); + el.setAttribute('aria-hidden', 'true'); + } catch (_) {} + }); + + player.querySelectorAll(SKIP_BTN_SEL).forEach(function (el) { + try { + el.style.setProperty('display', 'none', 'important'); + el.style.setProperty('visibility', 'hidden', 'important'); + el.style.setProperty('pointer-events', 'none', 'important'); + el.setAttribute('aria-hidden', 'true'); + } catch (_) {} + }); + } + function purgeStaticAds() { document.querySelectorAll(STATIC_AD_SEL).forEach(function (el) { el.remove(); @@ -203,19 +454,34 @@ if (!hasBlockedDialog) return; + reportDiagnostic('blocked-dialog', { + signal: 'youtube-enforcement-or-premium', + details: { + pageType: getPageType() + } + }); + + // Aggressively remove all modal backdrops document.querySelectorAll(MODAL_BACKDROP_SEL).forEach(function (el) { try { el.removeAttribute('opened'); } catch (_) {} try { el.classList.remove('opened'); } catch (_) {} try { el.style.setProperty('display', 'none', 'important'); } catch (_) {} try { el.style.setProperty('pointer-events', 'none', 'important'); } catch (_) {} + try { el.style.setProperty('visibility', 'hidden', 'important'); } catch (_) {} + try { el.style.setProperty('opacity', '0', 'important'); } catch (_) {} try { el.remove(); } catch (_) {} }); + // Unlock scrolling on html and body - use multiple approaches if (document.body) { - try { document.body.style.removeProperty('overflow'); } catch (_) {} - try { document.body.style.removeProperty('pointer-events'); } catch (_) {} + try { document.body.style.setProperty('overflow', 'visible', 'important'); } catch (_) {} + try { document.body.style.setProperty('pointer-events', 'auto', 'important'); } catch (_) {} + try { document.body.style.setProperty('max-height', 'none', 'important'); } catch (_) {} + try { document.body.style.setProperty('max-width', 'none', 'important'); } catch (_) {} } - try { document.documentElement.style.removeProperty('overflow'); } catch (_) {} + try { document.documentElement.style.setProperty('overflow', 'auto', 'important'); } catch (_) {} + try { document.documentElement.style.setProperty('pointer-events', 'auto', 'important'); } catch (_) {} + try { document.documentElement.style.setProperty('max-height', 'none', 'important'); } catch (_) {} } /* ── Core: nuke one frame of ad ──────────────────────────────── */ @@ -249,6 +515,20 @@ // 4. Hide leftover overlay elements hideAdOverlays(); + hidePlayerPromoSurfaces(player); + purgeBlockedYoutubePopups(); + } + + function suppressPromotedPlayerSurface(player) { + var video = player.querySelector('video'); + if (video) { + video.muted = true; + } + + clickSkipButtons(); + hideAdOverlays(); + hidePlayerPromoSurfaces(player); + purgeStaticAds(); purgeBlockedYoutubePopups(); } @@ -258,11 +538,17 @@ if (adLoopId !== null || adIntervalId !== null) return; var step = function () { - if (!playerInAdMode(player)) { + if (!playerNeedsIntervention(player)) { endAdLoop(player); return; } - nukeAdFrame(player); + + if (playerInAdMode(player)) { + nukeAdFrame(player); + return; + } + + suppressPromotedPlayerSurface(player); }; // setInterval at 16ms for reliable firing even when CSS hides the video @@ -273,8 +559,14 @@ // Also keep rAF as secondary mechanism for when the tab is active var rAfStep = function () { - if (!playerInAdMode(player)) return; - nukeAdFrame(player); + if (!playerNeedsIntervention(player)) return; + + if (playerInAdMode(player)) { + nukeAdFrame(player); + } else { + suppressPromotedPlayerSurface(player); + } + adLoopId = requestAnimationFrame(rAfStep); }; adLoopId = requestAnimationFrame(rAfStep); @@ -298,50 +590,9 @@ video.muted = savedMuted; video.volume = savedVolume; video.playbackRate = 1; // safety: ensure normal speed - - // Register one-shot listeners to reset currentTime if the real video - // inherited a wrong position from the ad-skip seeks - var resetDone = false; - var resetIfNeeded = function () { - if (resetDone) return; - - // Only act within 5 seconds of ad ending - if (Date.now() - adEndTimestamp > 5000) { - cleanup(); - return; - } - - // If not in ad mode and currentTime is suspiciously high - if (!playerInAdMode(player) && video.currentTime > 2) { - var targetTime = getUrlStartTime(); - video.currentTime = targetTime; - resetDone = true; - cleanup(); - } - }; - - var cleanup = function () { - video.removeEventListener('playing', resetIfNeeded, true); - video.removeEventListener('loadeddata', resetIfNeeded, true); - video.removeEventListener('timeupdate', resetIfNeeded, true); - wasInAdMode = false; - }; - - video.addEventListener('playing', resetIfNeeded, true); - video.addEventListener('loadeddata', resetIfNeeded, true); - video.addEventListener('timeupdate', resetIfNeeded, true); - - // Autoplay: the ad-skip sequence often leaves the real video paused. - // Wait briefly for the real video to load, then trigger play. - var ensurePlay = function () { - if (playerInAdMode(player)) return; - if (video.paused && video.readyState >= 2) { - video.play().catch(function () {}); - } - }; - setTimeout(ensurePlay, 100); - setTimeout(ensurePlay, 300); - setTimeout(ensurePlay, 800); + queuePostAdRecovery(player, video); + } else { + wasInAdMode = false; } adHandling = false; @@ -350,16 +601,22 @@ /* ── State-change dispatcher ─────────────────────────────────── */ function onPlayerStateChange(player) { - if (playerInAdMode(player)) { + if (playerNeedsIntervention(player)) { if (!adHandling) { adHandling = true; adSeekedToEnd = false; + if (!playerInAdMode(player) && playerHasPromotedSurface(player)) { + reportPromotedPlayerSurface(player); + } + // Snapshot audio state BEFORE we mute var video = player.querySelector('video'); if (video) { savedMuted = video.muted; savedVolume = video.volume; + realVideoStartTime = getResumeTargetTime(player, video); + postAdRecoveryToken += 1; // One-shot listener: seek as soon as duration is known var onMeta = function () { @@ -376,7 +633,12 @@ } // Immediate first attempt (don't wait for rAF/interval) - nukeAdFrame(player); + if (playerInAdMode(player)) { + nukeAdFrame(player); + } else { + suppressPromotedPlayerSurface(player); + } + beginAdLoop(player); } else if (adHandling) { endAdLoop(player); @@ -403,15 +665,8 @@ if (video.playbackRate !== 1) { video.playbackRate = 1; } - // Post-ad reset: if we recently exited ad mode and time is wrong - if (wasInAdMode && video.currentTime > 2) { - var targetTime = getUrlStartTime(); - video.currentTime = targetTime; - wasInAdMode = false; - } - // Ensure autoplay after ad skip - if (video.paused && video.readyState >= 2) { - video.play().catch(function () {}); + if (wasInAdMode || Date.now() - adEndTimestamp < 5000) { + ensurePlayback(player, video); } } }, true); @@ -432,11 +687,24 @@ return; } + var pending = false; + var schedulePlayerCheck = function () { + if (pending) return; + pending = true; + queueMicrotask(function () { + pending = false; + onPlayerStateChange(player); + }); + }; + onPlayerStateChange(player); - new MutationObserver(function () { - onPlayerStateChange(player); - }).observe(player, { attributes: true, attributeFilter: ['class'] }); + new MutationObserver(schedulePlayerCheck).observe(player, { + attributes: true, + attributeFilter: ['class', 'style', 'hidden', 'aria-hidden'], + childList: true, + subtree: true + }); } function attachBodyObserver() { @@ -473,6 +741,19 @@ setInterval(function () { purgeStaticAds(); purgeBlockedYoutubePopups(); + + // Ensure scrolling is always enabled (prevent YouTube from re-locking it) + try { + if (document.body && document.body.style.getPropertyValue('overflow') === 'hidden') { + document.body.style.setProperty('overflow', 'auto', 'important'); + document.body.style.setProperty('pointer-events', 'auto', 'important'); + } + if (document.documentElement && document.documentElement.style.getPropertyValue('overflow') === 'hidden') { + document.documentElement.style.setProperty('overflow', 'auto', 'important'); + document.documentElement.style.setProperty('pointer-events', 'auto', 'important'); + } + } catch (_) {} + var player = document.querySelector('#movie_player'); if (player) onPlayerStateChange(player); }, 500); @@ -494,15 +775,272 @@ '#movie_player.ad-interrupting .ytp-spinner-container' + '{display:none!important}' + + /* CRITICAL: Force scrolling enabled globally and permanently */ + 'html {' + + 'overflow:auto!important;' + + 'overflow-y:scroll!important;' + + 'height:auto!important;' + + 'pointer-events:auto!important;' + + '}' + + 'body {' + + 'overflow:visible!important;' + + 'overflow-y:scroll!important;' + + 'height:auto!important;' + + 'max-height:none!important;' + + 'pointer-events:auto!important;' + + 'position:static!important;' + + '}' + + 'ytd-rich-grid-renderer {overflow:visible!important}' + + AD_OVERLAY_SEL + '{display:none!important}'; (document.head || document.documentElement).appendChild(s); } + /* ── Aggressive scroll position override ────────────────────────────── */ + + function installAggressiveScrollUnlocker() { + var scrollMonitoringActive = true; + var protectedScrollY = 0; + var lastObservedScrollY = 0; + var userScrollDirection = 0; + var userScrollSessionUntil = 0; + var isRestoringScroll = false; + var originalScrollTo = window.scrollTo ? window.scrollTo.bind(window) : function () {}; + var originalScroll = window.scroll ? window.scroll.bind(window) : originalScrollTo; + + function getCurrentScrollY() { + return window.pageYOffset || document.documentElement.scrollTop || (document.body && document.body.scrollTop) || 0; + } + + function getScrollTargetY(argsLike) { + if (typeof ytUtils.extractScrollTargetY === 'function') { + return ytUtils.extractScrollTargetY(argsLike); + } + if (!argsLike || argsLike.length === 0) return null; + if (argsLike[0] && typeof argsLike[0] === 'object') { + return Number.isFinite(argsLike[0].top) ? argsLike[0].top : null; + } + return Number.isFinite(argsLike[1]) ? argsLike[1] : null; + } + + function getScrollDirection(previousY, nextY) { + if (typeof ytUtils.getScrollDirectionFromPositions === 'function') { + return ytUtils.getScrollDirectionFromPositions(previousY, nextY); + } + if (nextY > previousY + 2) return 1; + if (nextY < previousY - 2) return -1; + return 0; + } + + function shouldBlockProgrammaticScroll(targetY) { + if (typeof ytUtils.shouldBlockProgrammaticScroll === 'function') { + return ytUtils.shouldBlockProgrammaticScroll( + targetY, + protectedScrollY, + userScrollDirection, + userScrollSessionUntil, + Date.now() + ); + } + + if (!Number.isFinite(targetY) || !userScrollDirection || Date.now() > userScrollSessionUntil) { + return false; + } + + return userScrollDirection > 0 ? targetY < protectedScrollY - 120 : targetY > protectedScrollY + 120; + } + + function shouldRecoverScrollPosition(currentY) { + if (typeof ytUtils.shouldRecoverScrollPosition === 'function') { + return ytUtils.shouldRecoverScrollPosition( + currentY, + protectedScrollY, + userScrollDirection, + userScrollSessionUntil, + Date.now() + ); + } + + if (!Number.isFinite(currentY) || !userScrollDirection || Date.now() > userScrollSessionUntil) { + return false; + } + + return userScrollDirection > 0 ? currentY < protectedScrollY - 140 : currentY > protectedScrollY + 140; + } + + function markUserScroll(direction) { + userScrollSessionUntil = Date.now() + 1500; + if (direction) { + userScrollDirection = direction; + } + } + + function trackObservedScroll() { + if (isRestoringScroll) return; + + var currentScrollY = getCurrentScrollY(); + var derivedDirection = getScrollDirection(lastObservedScrollY, currentScrollY); + + if (derivedDirection) { + userScrollDirection = derivedDirection; + } + + if (Date.now() <= userScrollSessionUntil) { + protectedScrollY = currentScrollY; + } + + lastObservedScrollY = currentScrollY; + } + + protectedScrollY = getCurrentScrollY(); + lastObservedScrollY = protectedScrollY; + + window.scrollTo = function () { + var targetY = getScrollTargetY(arguments); + if (!isRestoringScroll && shouldBlockProgrammaticScroll(targetY)) { + return; + } + return originalScrollTo.apply(window, arguments); + }; + + window.scroll = function () { + var targetY = getScrollTargetY(arguments); + if (!isRestoringScroll && shouldBlockProgrammaticScroll(targetY)) { + return; + } + return originalScroll.apply(window, arguments); + }; + + var scrollMonitor = setInterval(function () { + if (!scrollMonitoringActive || isRestoringScroll) return; + + try { + var currentScrollY = getCurrentScrollY(); + if (shouldRecoverScrollPosition(currentScrollY)) { + isRestoringScroll = true; + originalScrollTo(0, protectedScrollY); + lastObservedScrollY = protectedScrollY; + setTimeout(function () { + isRestoringScroll = false; + }, 80); + return; + } + + trackObservedScroll(); + } catch (_) {} + }, 80); + + var onScroll = function () { + trackObservedScroll(); + }; + + var onWheel = function (e) { + if (Math.abs(e.deltaY) < 1) return; + markUserScroll(e.deltaY > 0 ? 1 : -1); + }; + + var onKeyDown = function (e) { + var direction = 0; + + if (e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === ' ' || e.key === 'End') { + direction = 1; + } else if (e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'Home') { + direction = -1; + } + + if (direction) { + markUserScroll(direction); + } + }; + + var onPointerDown = function () { + markUserScroll(0); + }; + + document.addEventListener('scroll', onScroll, { capture: true, passive: true }); + window.addEventListener('scroll', onScroll, { capture: true, passive: true }); + document.addEventListener('wheel', onWheel, { capture: true, passive: true }); + window.addEventListener('wheel', onWheel, { capture: true, passive: true }); + document.addEventListener('keydown', onKeyDown, true); + document.addEventListener('mousedown', onPointerDown, true); + document.addEventListener('touchstart', onPointerDown, { capture: true, passive: true }); + document.addEventListener('touchmove', onPointerDown, { capture: true, passive: true }); + + // 5. Prevent scroll-related CSS from locking + var styleMonitor = setInterval(function () { + try { + ['html', 'body'].forEach(function (selector) { + var el = selector === 'html' ? document.documentElement : document.body; + if (!el) return; + + // Remove height locks + if (el.style.height === '100%' || el.style.height === '100vh') { + el.style.setProperty('height', 'auto', 'important'); + } + if (el.style.maxHeight && el.style.maxHeight !== 'none') { + el.style.setProperty('max-height', 'none', 'important'); + } + + // Remove overflow locks + if (el.style.overflow === 'hidden') { + el.style.setProperty('overflow', selector === 'html' ? 'auto' : 'visible', 'important'); + } + + // Restore pointer events + if (el.style.pointerEvents === 'none') { + el.style.setProperty('pointer-events', 'auto', 'important'); + } + }); + } catch (_) {} + }, 200); + + // 6. Force enable scrolling at CSS level permanently + var styleSheet = document.createElement('style'); + styleSheet.id = 'adeclipse-scroll-override'; + styleSheet.textContent = + 'html { overflow-y: auto !important; width: 100% !important; height: auto !important; }' + + 'body { overflow: visible !important; overflow-y: auto !important; width: 100% !important; height: auto !important; position: static !important; }' + + /* Block any element trying to prevent scrolling */ + '[style*="overflow"][style*="hidden"] { overflow: visible !important; }' + + '[style*="position"][style*="fixed"] > * { position: relative !important; }'; + + (document.head || document.documentElement).appendChild(styleSheet); + + // 7. Monitor modal backdrops in real-time and kill them + var backdropKiller = setInterval(function () { + try { + document.querySelectorAll('tp-yt-iron-overlay-backdrop').forEach(function (backdrop) { + if (backdrop.style.display !== 'none') { + backdrop.style.setProperty('display', 'none', 'important'); + backdrop.style.setProperty('pointer-events', 'none', 'important'); + backdrop.style.setProperty('visibility', 'hidden', 'important'); + } + }); + } catch (_) {} + }, 150); + + // Cleanup function if needed + return function cleanup() { + scrollMonitoringActive = false; + clearInterval(scrollMonitor); + clearInterval(styleMonitor); + clearInterval(backdropKiller); + document.removeEventListener('scroll', onScroll, true); + window.removeEventListener('scroll', onScroll, true); + document.removeEventListener('wheel', onWheel, true); + window.removeEventListener('wheel', onWheel, true); + document.removeEventListener('keydown', onKeyDown, true); + document.removeEventListener('mousedown', onPointerDown, true); + }; + } + /* ── Bootstrap ───────────────────────────────────────────────── */ function bootstrapAdBlocker() { injectEarlyStyle(); + attachMainWorldDiagnosticsBridge(); + installAggressiveScrollUnlocker(); purgeStaticAds(); purgeBlockedYoutubePopups(); attachPlayerObserver(); diff --git a/src/ml/ai-cache.js b/src/ml/ai-cache.js new file mode 100644 index 0000000..f248eb7 --- /dev/null +++ b/src/ml/ai-cache.js @@ -0,0 +1,205 @@ +/** + * AdEclipse AI Cache + * LRU in-memory cache with chrome.storage.local persistence and domain-level pattern learning + */ + +class AICache { + constructor() { + this.memoryCache = new Map(); + this.maxMemoryEntries = 1000; + this.defaultTtlMs = 24 * 60 * 60 * 1000; + this.patternCache = new Map(); + this.storageKey = 'adeclipse_ai_cache'; + this.patternStorageKey = 'adeclipse_ai_patterns'; + this._loaded = false; + } + + async init(options = {}) { + if (options.cacheDurationHours) { + this.defaultTtlMs = options.cacheDurationHours * 60 * 60 * 1000; + } + await this._loadFromStorage(); + this._loaded = true; + } + + generateKey(domain, elementSignature) { + const raw = `${domain}||${elementSignature}`; + return this._hashString(raw); + } + + generateElementSignature(descriptor) { + const parts = [ + descriptor.tag || '', + (descriptor.classes || []).sort().join(','), + descriptor.id || '', + Math.round((descriptor.width || 0) / 10) * 10, + Math.round((descriptor.height || 0) / 10) * 10, + descriptor.hasIframe ? 'iframe' : '', + descriptor.externalLinkCount > 0 ? 'ext' : '' + ]; + return parts.join('|'); + } + + get(domain, elementSignature) { + const key = this.generateKey(domain, elementSignature); + const entry = this.memoryCache.get(key); + + if (!entry) { + return this._checkPatternCache(domain, elementSignature); + } + + if (Date.now() > entry.expiresAt) { + this.memoryCache.delete(key); + return null; + } + + this.memoryCache.delete(key); + this.memoryCache.set(key, entry); + + return entry.verdict; + } + + set(domain, elementSignature, verdict, ttlMs) { + const key = this.generateKey(domain, elementSignature); + const expiresAt = Date.now() + (ttlMs || this.defaultTtlMs); + + if (this.memoryCache.size >= this.maxMemoryEntries) { + const firstKey = this.memoryCache.keys().next().value; + this.memoryCache.delete(firstKey); + } + + this.memoryCache.set(key, { verdict, expiresAt, domain, signature: elementSignature }); + + if (verdict.isAd && verdict.confidence >= 0.8) { + this._learnPattern(domain, elementSignature, verdict); + } + } + + setBatch(domain, results) { + for (const result of results) { + if (result.elementSignature) { + this.set(domain, result.elementSignature, { + isAd: result.isAd, + confidence: result.confidence, + adType: result.adType, + reason: result.reason + }); + } + } + } + + _learnPattern(domain, signature, verdict) { + const parts = signature.split('|'); + const classes = parts[1] || ''; + if (!classes) return; + + const patternKey = `${domain}||class:${classes}`; + const existing = this.patternCache.get(patternKey); + + if (existing) { + existing.hitCount++; + existing.lastSeen = Date.now(); + } else { + this.patternCache.set(patternKey, { + verdict: { isAd: verdict.isAd, confidence: verdict.confidence, adType: verdict.adType, reason: 'pattern-match' }, + hitCount: 1, + lastSeen: Date.now() + }); + } + } + + _checkPatternCache(domain, elementSignature) { + const parts = elementSignature.split('|'); + const classes = parts[1] || ''; + if (!classes) return null; + + const patternKey = `${domain}||class:${classes}`; + const pattern = this.patternCache.get(patternKey); + + if (pattern && pattern.hitCount >= 2) { + return pattern.verdict; + } + return null; + } + + async persist() { + try { + const entries = {}; + const now = Date.now(); + let count = 0; + + for (const [key, entry] of this.memoryCache) { + if (entry.expiresAt > now && count < 500) { + entries[key] = entry; + count++; + } + } + + const patterns = {}; + for (const [key, pattern] of this.patternCache) { + if (pattern.hitCount >= 2) { + patterns[key] = pattern; + } + } + + await chrome.storage.local.set({ + [this.storageKey]: entries, + [this.patternStorageKey]: patterns + }); + } catch (error) { + console.error('[AdEclipse AI Cache] Persist error:', error); + } + } + + async _loadFromStorage() { + try { + const data = await chrome.storage.local.get([this.storageKey, this.patternStorageKey]); + const now = Date.now(); + + if (data[this.storageKey]) { + for (const [key, entry] of Object.entries(data[this.storageKey])) { + if (entry.expiresAt > now) { + this.memoryCache.set(key, entry); + } + } + } + + if (data[this.patternStorageKey]) { + for (const [key, pattern] of Object.entries(data[this.patternStorageKey])) { + this.patternCache.set(key, pattern); + } + } + } catch (error) { + console.error('[AdEclipse AI Cache] Load error:', error); + } + } + + clear() { + this.memoryCache.clear(); + this.patternCache.clear(); + try { + chrome.storage.local.remove([this.storageKey, this.patternStorageKey]); + } catch (e) { /* ignore in non-extension contexts */ } + } + + getStats() { + return { + memoryCacheSize: this.memoryCache.size, + patternCacheSize: this.patternCache.size, + maxMemoryEntries: this.maxMemoryEntries, + ttlHours: this.defaultTtlMs / (60 * 60 * 1000) + }; + } + + _hashString(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return 'aic_' + Math.abs(hash).toString(36); + } +} + +export { AICache }; diff --git a/src/ml/ai-detector.js b/src/ml/ai-detector.js new file mode 100644 index 0000000..36b7d41 --- /dev/null +++ b/src/ml/ai-detector.js @@ -0,0 +1,201 @@ +/** + * AdEclipse AI Detector + * Orchestrates LLM-based ad detection: batching, caching, parsing + */ + +import { AIProvider } from './ai-provider.js'; +import { AICache } from './ai-cache.js'; +import { buildMessages } from './prompt-templates.js'; + +class AIAdDetector { + constructor() { + this.provider = new AIProvider(); + this.cache = new AICache(); + this.scanMode = 'smart'; + this.confidenceThreshold = 0.7; + this.maxElementsPerBatch = 30; + this.initialized = false; + this._pendingRequests = new Map(); + } + + async init(settings) { + if (!settings?.ai?.enabled) return false; + + const aiSettings = settings.ai; + this.scanMode = aiSettings.scanMode || 'smart'; + this.confidenceThreshold = aiSettings.confidenceThreshold ?? 0.7; + this.maxElementsPerBatch = aiSettings.maxElementsPerBatch || 30; + + this.provider.configure(aiSettings); + await this.cache.init({ cacheDurationHours: aiSettings.cacheDurationHours || 24 }); + + this.initialized = true; + return true; + } + + async scanElements(elementDescriptors, domain) { + if (!this.initialized || !elementDescriptors.length) { + return elementDescriptors.map(el => ({ + elementId: el.id, + isAd: false, + confidence: 0, + adType: 'none', + reason: 'AI not initialized', + source: 'skip' + })); + } + + const results = new Array(elementDescriptors.length); + const uncachedElements = []; + const uncachedIndices = []; + + for (let i = 0; i < elementDescriptors.length; i++) { + const el = elementDescriptors[i]; + const signature = this.cache.generateElementSignature(el); + const cached = this.cache.get(domain, signature); + + if (cached) { + results[i] = { elementId: el.id, ...cached, source: 'cache' }; + } else { + el._signature = signature; + uncachedElements.push(el); + uncachedIndices.push(i); + } + } + + if (uncachedElements.length === 0) return results; + + const batches = this._chunk(uncachedElements, this.maxElementsPerBatch); + let batchOffset = 0; + + for (const batch of batches) { + try { + const verdicts = await this._analyzeWithLLM(batch, domain); + + for (let j = 0; j < batch.length; j++) { + const verdict = verdicts[j] || { + isAd: false, confidence: 0, adType: 'none', reason: 'No response' + }; + + const globalIndex = uncachedIndices[batchOffset + j]; + const el = batch[j]; + + results[globalIndex] = { + elementId: el.id, + isAd: verdict.isAd && verdict.confidence >= this.confidenceThreshold, + confidence: verdict.confidence, + adType: verdict.adType || 'none', + reason: verdict.reason || '', + source: 'ai' + }; + + this.cache.set(domain, el._signature, { + isAd: verdict.isAd, + confidence: verdict.confidence, + adType: verdict.adType || 'none', + reason: verdict.reason || '' + }); + } + } catch (error) { + console.error('[AdEclipse AI] Batch analysis error:', error); + for (let j = 0; j < batch.length; j++) { + const globalIndex = uncachedIndices[batchOffset + j]; + results[globalIndex] = { + elementId: batch[j].id, + isAd: false, + confidence: 0, + adType: 'none', + reason: `Error: ${error.message}`, + source: 'error' + }; + } + } + batchOffset += batch.length; + } + + this.cache.persist().catch(() => {}); + + return results; + } + + async _analyzeWithLLM(elements, domain) { + const messages = buildMessages(domain, elements, elements.length <= 10); + + const response = await this.provider.sendChatCompletion(messages, { + temperature: 0.1, + maxTokens: Math.min(elements.length * 150, 4096), + responseFormat: { type: 'json_object' } + }); + + return this._parseResponse(response.content, elements); + } + + _parseResponse(content, elements) { + try { + let cleaned = content.trim(); + + if (cleaned.startsWith('```')) { + cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, ''); + } + + let parsed = JSON.parse(cleaned); + + if (parsed && !Array.isArray(parsed)) { + const arrayKey = Object.keys(parsed).find(k => Array.isArray(parsed[k])); + if (arrayKey) { + parsed = parsed[arrayKey]; + } else { + parsed = [parsed]; + } + } + + if (!Array.isArray(parsed)) { + return elements.map(() => ({ isAd: false, confidence: 0, adType: 'none', reason: 'Parse error' })); + } + + return elements.map((el, i) => { + const match = parsed[i] + || parsed.find(p => p.id === el.id) + || { isAd: false, confidence: 0, adType: 'none', reason: 'Not found in response' }; + + return { + isAd: !!match.isAd, + confidence: typeof match.confidence === 'number' ? Math.max(0, Math.min(1, match.confidence)) : 0, + adType: match.adType || 'none', + reason: (match.reason || '').slice(0, 100) + }; + }); + } catch (error) { + console.error('[AdEclipse AI] Response parse error:', error, content?.slice(0, 200)); + return elements.map(() => ({ isAd: false, confidence: 0, adType: 'none', reason: 'Parse error' })); + } + } + + _chunk(array, size) { + const chunks = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; + } + + async testConnection(settings) { + const tempProvider = new AIProvider(); + tempProvider.configure(settings); + return tempProvider.testConnection(); + } + + getUsageStats() { + return this.provider.getUsageStats(); + } + + getCacheStats() { + return this.cache.getStats(); + } + + clearCache() { + this.cache.clear(); + } +} + +export { AIAdDetector }; diff --git a/src/ml/ai-provider.js b/src/ml/ai-provider.js new file mode 100644 index 0000000..b95e4b9 --- /dev/null +++ b/src/ml/ai-provider.js @@ -0,0 +1,351 @@ +/** + * AdEclipse AI Provider + * Multi-provider LLM client supporting OpenAI, Anthropic, OpenRouter, Groq, and custom endpoints + */ + +const PROVIDER_PRESETS = { + openai: { + name: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + models: [ + { id: 'gpt-4o-mini', name: 'GPT-4o Mini (Fast, Cheap)', maxTokens: 4096 }, + { id: 'gpt-4o', name: 'GPT-4o (Balanced)', maxTokens: 4096 }, + { id: 'gpt-4-turbo', name: 'GPT-4 Turbo (Powerful)', maxTokens: 4096 } + ], + authType: 'bearer', + format: 'openai' + }, + anthropic: { + name: 'Anthropic', + baseUrl: 'https://api.anthropic.com/v1', + models: [ + { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4 (Balanced)', maxTokens: 4096 }, + { id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku (Fast)', maxTokens: 4096 } + ], + authType: 'x-api-key', + format: 'anthropic' + }, + openrouter: { + name: 'OpenRouter', + baseUrl: 'https://openrouter.ai/api/v1', + models: [ + { id: 'openai/gpt-4o-mini', name: 'GPT-4o Mini', maxTokens: 4096 }, + { id: 'anthropic/claude-sonnet-4-20250514', name: 'Claude Sonnet 4', maxTokens: 4096 }, + { id: 'google/gemini-2.5-flash-preview', name: 'Gemini 2.5 Flash', maxTokens: 4096 }, + { id: 'meta-llama/llama-3.3-70b-instruct', name: 'Llama 3.3 70B', maxTokens: 4096 } + ], + authType: 'bearer', + format: 'openai' + }, + groq: { + name: 'Groq', + baseUrl: 'https://api.groq.com/openai/v1', + models: [ + { id: 'llama-3.3-70b-versatile', name: 'Llama 3.3 70B (Fast)', maxTokens: 4096 }, + { id: 'mixtral-8x7b-32768', name: 'Mixtral 8x7B', maxTokens: 4096 }, + { id: 'gemma2-9b-it', name: 'Gemma 2 9B', maxTokens: 4096 } + ], + authType: 'bearer', + format: 'openai' + }, + custom: { + name: 'Custom (OpenAI-compatible)', + baseUrl: '', + models: [], + authType: 'bearer', + format: 'openai' + } +}; + +class AIProvider { + constructor() { + this.config = null; + this.usageStats = { totalTokens: 0, promptTokens: 0, completionTokens: 0, totalRequests: 0 }; + this.maxRetries = 2; + this.timeoutMs = 30000; + } + + configure(settings) { + const providerKey = settings.provider || 'openai'; + const preset = PROVIDER_PRESETS[providerKey]; + if (!preset) throw new Error(`Unknown provider: ${providerKey}`); + + this.config = { + providerKey, + format: preset.format, + authType: preset.authType, + apiKey: settings.apiKey || '', + baseUrl: providerKey === 'custom' + ? (settings.customBaseUrl || '').replace(/\/+$/, '') + : preset.baseUrl, + model: settings.model || preset.models[0]?.id || '', + maxTokens: 2048 + }; + } + + async sendChatCompletion(messages, options = {}) { + if (!this.config) throw new Error('Provider not configured. Call configure() first.'); + if (!this.config.apiKey) throw new Error('API key not set.'); + + const { format } = this.config; + let lastError = null; + + for (let attempt = 0; attempt <= this.maxRetries; attempt++) { + try { + if (attempt > 0) { + await this._sleep(Math.min(1000 * Math.pow(2, attempt), 8000)); + } + + const response = format === 'anthropic' + ? await this._sendAnthropic(messages, options) + : await this._sendOpenAI(messages, options); + + this.usageStats.totalRequests++; + if (response.usage) { + this.usageStats.promptTokens += response.usage.promptTokens || 0; + this.usageStats.completionTokens += response.usage.completionTokens || 0; + this.usageStats.totalTokens += response.usage.totalTokens || 0; + } + + return response; + } catch (error) { + lastError = error; + if (error.status === 401 || error.status === 403) throw error; + if (error.status === 400) throw error; + if (attempt === this.maxRetries) throw error; + } + } + + throw lastError; + } + + async _sendOpenAI(messages, options) { + const url = `${this.config.baseUrl}/chat/completions`; + const body = { + model: this.config.model, + messages, + max_tokens: options.maxTokens || this.config.maxTokens, + temperature: options.temperature ?? 0.1, + }; + + if (options.responseFormat) { + body.response_format = options.responseFormat; + } + + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.config.apiKey}` + }; + + if (this.config.providerKey === 'openrouter') { + headers['HTTP-Referer'] = 'chrome-extension://adeclipse'; + headers['X-Title'] = 'AdEclipse Ad Blocker'; + } + + const resp = await this._fetchWithTimeout(url, { + method: 'POST', + headers, + body: JSON.stringify(body) + }); + + if (!resp.ok) { + const errorData = await resp.json().catch(() => ({})); + const error = new Error(errorData.error?.message || `API error: ${resp.status}`); + error.status = resp.status; + throw error; + } + + const data = await resp.json(); + const choice = data.choices?.[0]; + + return { + content: choice?.message?.content || '', + finishReason: choice?.finish_reason, + usage: data.usage ? { + promptTokens: data.usage.prompt_tokens || 0, + completionTokens: data.usage.completion_tokens || 0, + totalTokens: data.usage.total_tokens || 0 + } : null + }; + } + + async _sendAnthropic(messages, options) { + const url = `${this.config.baseUrl}/messages`; + + const systemMsg = messages.find(m => m.role === 'system'); + const nonSystemMsgs = messages.filter(m => m.role !== 'system'); + + const body = { + model: this.config.model, + max_tokens: options.maxTokens || this.config.maxTokens, + messages: nonSystemMsgs.map(m => ({ + role: m.role, + content: m.content + })) + }; + + if (systemMsg) { + body.system = systemMsg.content; + } + + const headers = { + 'Content-Type': 'application/json', + 'x-api-key': this.config.apiKey, + 'anthropic-version': '2023-06-01' + }; + + const resp = await this._fetchWithTimeout(url, { + method: 'POST', + headers, + body: JSON.stringify(body) + }); + + if (!resp.ok) { + const errorData = await resp.json().catch(() => ({})); + const error = new Error(errorData.error?.message || `API error: ${resp.status}`); + error.status = resp.status; + throw error; + } + + const data = await resp.json(); + const textBlock = data.content?.find(b => b.type === 'text'); + + return { + content: textBlock?.text || '', + finishReason: data.stop_reason, + usage: data.usage ? { + promptTokens: data.usage.input_tokens || 0, + completionTokens: data.usage.output_tokens || 0, + totalTokens: (data.usage.input_tokens || 0) + (data.usage.output_tokens || 0) + } : null + }; + } + + async testConnection() { + const messages = [ + { role: 'system', content: 'Respond with exactly: {"status":"ok"}' }, + { role: 'user', content: 'ping' } + ]; + + try { + const response = await this.sendChatCompletion(messages, { maxTokens: 20, temperature: 0 }); + return { + success: true, + model: this.config.model, + provider: this.config.providerKey, + latencyMs: null, + message: `Connected to ${PROVIDER_PRESETS[this.config.providerKey]?.name || 'Custom'}` + }; + } catch (error) { + return { + success: false, + error: error.message, + status: error.status + }; + } + } + + async _fetchWithTimeout(url, options) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } catch (error) { + if (error.name === 'AbortError') { + const timeoutError = new Error(`Request timed out after ${this.timeoutMs}ms`); + timeoutError.status = 408; + throw timeoutError; + } + throw error; + } finally { + clearTimeout(timer); + } + } + + _sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + getUsageStats() { + return { ...this.usageStats }; + } + + resetUsageStats() { + this.usageStats = { totalTokens: 0, promptTokens: 0, completionTokens: 0, totalRequests: 0 }; + } + + static getProviders() { + return Object.entries(PROVIDER_PRESETS).map(([key, preset]) => ({ + id: key, + name: preset.name, + models: preset.models + })); + } + + static getModelsForProvider(providerKey) { + return PROVIDER_PRESETS[providerKey]?.models || []; + } + + static async fetchRemoteModels(providerKey, apiKey) { + if (!apiKey) return PROVIDER_PRESETS[providerKey]?.models || []; + + if (providerKey === 'openrouter') { + const resp = await fetch('https://openrouter.ai/api/v1/models', { + headers: { 'Authorization': `Bearer ${apiKey}` } + }); + if (!resp.ok) { + throw new Error(`Failed to fetch models: ${resp.status}`); + } + const data = await resp.json(); + return (data.data || []) + .map(m => ({ + id: m.id, + name: m.name || m.id, + maxTokens: m.context_length || 4096, + pricing: m.pricing ? { + prompt: m.pricing.prompt, + completion: m.pricing.completion + } : null + })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + if (providerKey === 'openai') { + try { + const resp = await fetch('https://api.openai.com/v1/models', { + headers: { 'Authorization': `Bearer ${apiKey}` } + }); + if (!resp.ok) throw new Error(`${resp.status}`); + const data = await resp.json(); + const chatModels = (data.data || []) + .filter(m => m.id.startsWith('gpt-') || m.id.startsWith('o')) + .map(m => ({ id: m.id, name: m.id, maxTokens: 4096 })) + .sort((a, b) => a.name.localeCompare(b.name)); + return chatModels.length > 0 ? chatModels : PROVIDER_PRESETS.openai.models; + } catch (e) { + return PROVIDER_PRESETS.openai.models; + } + } + + if (providerKey === 'groq') { + try { + const resp = await fetch('https://api.groq.com/openai/v1/models', { + headers: { 'Authorization': `Bearer ${apiKey}` } + }); + if (!resp.ok) throw new Error(`${resp.status}`); + const data = await resp.json(); + const models = (data.data || []) + .filter(m => m.active !== false) + .map(m => ({ id: m.id, name: m.id, maxTokens: m.context_window || 4096 })) + .sort((a, b) => a.name.localeCompare(b.name)); + return models.length > 0 ? models : PROVIDER_PRESETS.groq.models; + } catch (e) { + return PROVIDER_PRESETS.groq.models; + } + } + + return PROVIDER_PRESETS[providerKey]?.models || []; + } +} + +export { AIProvider, PROVIDER_PRESETS }; diff --git a/src/ml/detector.js b/src/ml/detector.js index f75a0d2..3f6ef78 100644 --- a/src/ml/detector.js +++ b/src/ml/detector.js @@ -151,7 +151,7 @@ class MLAdDetector { // Link features const links = element.querySelectorAll('a'); - const externalLinks = Array.from(links).filter(a => + const externalLinks = Array.from(links).filter(a => a.href && !a.href.includes(window.location.hostname) ).length; features.push(externalLinks / (links.length || 1)); @@ -187,7 +187,7 @@ class MLAdDetector { const tensor = tf.tensor2d([features]); const prediction = this.model.predict(tensor); const probabilities = await prediction.data(); - + // Cleanup tensors tensor.dispose(); prediction.dispose(); @@ -227,7 +227,7 @@ class MLAdDetector { const tensor = tf.tensor2d(features); const predictions = this.model.predict(tensor); const data = await predictions.data(); - + tensor.dispose(); predictions.dispose(); diff --git a/src/ml/features.js b/src/ml/features.js index 4a9c480..813a060 100644 --- a/src/ml/features.js +++ b/src/ml/features.js @@ -64,7 +64,7 @@ class FeatureExtractor { const viewportArea = viewportWidth * viewportHeight; // Check if matches common ad sizes - const isCommonAdSize = this.commonAdSizes.some(([w, h]) => + const isCommonAdSize = this.commonAdSizes.some(([w, h]) => Math.abs(rect.width - w) < 10 && Math.abs(rect.height - h) < 10 ); @@ -238,8 +238,8 @@ class FeatureExtractor { if (adNetworkDomains.some(d => url.hostname.includes(d))) { adNetworkLinks++; } - } catch (e) {} - + } catch (e) { } + if (link.target === '_blank') { blankTargetLinks++; } @@ -268,12 +268,12 @@ class FeatureExtractor { const rect = element.getBoundingClientRect(); const nearbyAds = document.querySelectorAll('[class*="ad"], [id*="ad"]'); let nearbyAdCount = 0; - + nearbyAds.forEach(ad => { if (ad !== element) { const adRect = ad.getBoundingClientRect(); const distance = Math.sqrt( - Math.pow(rect.left - adRect.left, 2) + + Math.pow(rect.left - adRect.left, 2) + Math.pow(rect.top - adRect.top, 2) ); if (distance < 500) nearbyAdCount++; diff --git a/src/ml/prompt-templates.js b/src/ml/prompt-templates.js new file mode 100644 index 0000000..0e48229 --- /dev/null +++ b/src/ml/prompt-templates.js @@ -0,0 +1,133 @@ +/** + * AdEclipse AI Prompt Templates + * Structured prompts for LLM-based ad detection + */ + +const SYSTEM_PROMPT = `You are an expert ad detection system for a browser extension called AdEclipse. You analyze DOM element metadata from web pages and determine which elements are advertisements. + +## Your Task +Given a list of DOM elements described by their metadata (tag, classes, IDs, text, dimensions, structure), classify each as an ad or legitimate content. + +## What Counts as an Ad +- Display ads (Google AdSense, banner ads, rectangle ads, leaderboard ads) +- Sponsored/promoted posts or content +- Video advertisements (pre-roll, mid-roll, overlay) +- Affiliate marketing widgets and links +- Ad-network content (Taboola, Outbrain, RevContent, MGID, etc.) +- Newsletter/subscription overlays that block content +- Interstitial ads and full-page overlays +- Native ads disguised as content but served by ad networks +- "Recommended" or "Around the web" widgets from ad networks +- Shopping/product recommendation ads +- Social media promoted/boosted posts +- Tracking pixels and invisible ad containers +- Pop-under and pop-up ad containers + +## What is NOT an Ad +- The site's own navigation, header, footer, sidebar menus +- Genuine content: articles, posts, comments, user-generated content +- The site's own product listings or recommendations (not from ad networks) +- Essential UI: login forms, search bars, settings panels +- Social sharing buttons (native, not promoted) +- Legitimate embedded videos (YouTube player for actual content) +- Cookie consent banners (unless they are deceptive overlay ads) +- Site announcements or notification bars from the site itself + +## Response Format +Respond with ONLY a valid JSON array. Each element in the array corresponds to one input element (same order). Each object must have: +- "id": the element's identifier from the input +- "isAd": boolean +- "confidence": number 0.0-1.0 +- "adType": string (one of: "display", "sponsored", "video", "native", "overlay", "affiliate", "tracking", "widget", "none") +- "reason": brief explanation (max 15 words) + +Respond ONLY with the JSON array, no markdown fences, no commentary.`; + +function buildUserPrompt(domain, elements) { + const header = `Website: ${domain}\nElements to analyze (${elements.length} total):\n`; + + const elementDescriptions = elements.map((el, i) => { + const lines = [`[Element ${el.id || i}]`]; + lines.push(` Tag: <${el.tag}>`); + + if (el.id) lines.push(` ID: "${el.id}"`); + if (el.classes && el.classes.length) lines.push(` Classes: ${el.classes.join(', ')}`); + if (el.text) lines.push(` Text: "${el.text}"`); + + lines.push(` Size: ${el.width}x${el.height}px`); + if (el.position) lines.push(` Position: ${el.position}`); + + if (el.childTags && el.childTags.length) lines.push(` Children: ${el.childTags.join(', ')}`); + if (el.hasIframe) lines.push(` Contains: iframe`); + if (el.hasVideo) lines.push(` Contains: video`); + if (el.hasImage) lines.push(` Contains: image`); + + if (el.linkCount) lines.push(` Links: ${el.linkCount} (${el.externalLinkCount || 0} external)`); + if (el.dataAttributes && el.dataAttributes.length) { + lines.push(` Data attrs: ${el.dataAttributes.slice(0, 5).join(', ')}`); + } + if (el.ariaLabel) lines.push(` Aria-label: "${el.ariaLabel}"`); + if (el.role) lines.push(` Role: "${el.role}"`); + if (el.src) lines.push(` Src: "${el.src}"`); + + return lines.join('\n'); + }); + + return header + elementDescriptions.join('\n\n'); +} + +const FEW_SHOT_EXAMPLES = [ + { + role: 'user', + content: `Website: news.example.com +Elements to analyze (3 total): + +[Element el_0] + Tag:
+ Classes: ad-container, google-ad + Size: 728x90px + Position: top + Contains: iframe + Data attrs: data-ad-slot="1234567890" + +[Element el_1] + Tag:
+ Classes: post-content, article-body + Text: "Scientists discover new species in the deep ocean..." + Size: 800x1200px + Children: p, p, figure, p, p + Links: 3 (0 external) + +[Element el_2] + Tag:
+ Classes: taboola-widget, recommended-content + Text: "You May Also Like - Sponsored" + Size: 600x400px + Children: div, div, div, div + Links: 8 (8 external) + Data attrs: data-mode="alternating-thumbnails"` + }, + { + role: 'assistant', + content: `[{"id":"el_0","isAd":true,"confidence":0.98,"adType":"display","reason":"Google AdSense banner ad container with iframe"},{"id":"el_1","isAd":false,"confidence":0.95,"adType":"none","reason":"Main article content with internal links"},{"id":"el_2","isAd":true,"confidence":0.96,"adType":"widget","reason":"Taboola sponsored content widget"}]` + } +]; + +function buildMessages(domain, elements, includeExamples = true) { + const messages = [ + { role: 'system', content: SYSTEM_PROMPT } + ]; + + if (includeExamples) { + messages.push(...FEW_SHOT_EXAMPLES); + } + + messages.push({ + role: 'user', + content: buildUserPrompt(domain, elements) + }); + + return messages; +} + +export { SYSTEM_PROMPT, buildUserPrompt, buildMessages, FEW_SHOT_EXAMPLES }; diff --git a/src/options/options.css b/src/options/options.css index e2eb0c8..c812a8b 100644 --- a/src/options/options.css +++ b/src/options/options.css @@ -2,24 +2,24 @@ :root { /* Colors */ - --color-primary: #10B981; + --color-primary: #10b981; --color-primary-dark: #059669; - --color-primary-light: #34D399; - --color-danger: #EF4444; - --color-warning: #F59E0B; - --color-info: #3B82F6; - + --color-primary-light: #34d399; + --color-danger: #ef4444; + --color-warning: #f59e0b; + --color-info: #3b82f6; + /* Light theme */ - --bg-primary: #FFFFFF; - --bg-secondary: #F9FAFB; - --bg-tertiary: #F3F4F6; + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f3f4f6; --text-primary: #111827; - --text-secondary: #6B7280; - --text-muted: #9CA3AF; - --border-color: #E5E7EB; + --text-secondary: #6b7280; + --text-muted: #9ca3af; + --border-color: #e5e7eb; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - + /* Sizing */ --sidebar-width: 260px; --border-radius: 12px; @@ -30,7 +30,7 @@ --spacing-lg: 16px; --spacing-xl: 24px; --spacing-2xl: 32px; - + /* Transitions */ --transition-fast: 0.15s ease; --transition-normal: 0.25s ease; @@ -39,35 +39,38 @@ /* Dark theme */ @media (prefers-color-scheme: dark) { :root { - --bg-primary: #1F2937; + --bg-primary: #1f2937; --bg-secondary: #111827; --bg-tertiary: #374151; - --text-primary: #F9FAFB; - --text-secondary: #D1D5DB; - --text-muted: #9CA3AF; + --text-primary: #f9fafb; + --text-secondary: #d1d5db; + --text-muted: #9ca3af; --border-color: #374151; } } .dark { - --bg-primary: #1F2937; + --bg-primary: #1f2937; --bg-secondary: #111827; --bg-tertiary: #374151; - --text-primary: #F9FAFB; - --text-secondary: #D1D5DB; - --text-muted: #9CA3AF; + --text-primary: #f9fafb; + --text-secondary: #d1d5db; + --text-muted: #9ca3af; --border-color: #374151; } /* Reset */ -*, *::before, *::after { +*, +*::before, +*::after { box-sizing: border-box; margin: 0; padding: 0; } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; font-size: 14px; line-height: 1.6; color: var(--text-primary); @@ -113,7 +116,11 @@ body { .logo-text { font-size: 22px; font-weight: 700; - background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark)); + background: linear-gradient( + 135deg, + var(--color-primary), + var(--color-primary-dark) + ); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; @@ -175,8 +182,14 @@ body { } @keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } } .section-title { @@ -285,7 +298,11 @@ body { } input:checked + .slider { - background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark)); + background: linear-gradient( + 135deg, + var(--color-primary), + var(--color-primary-dark) + ); } input:checked + .slider::before { @@ -338,7 +355,11 @@ input:checked + .slider::before { .btn-primary { padding: var(--spacing-sm) var(--spacing-lg); border: none; - background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark)); + background: linear-gradient( + 135deg, + var(--color-primary), + var(--color-primary-dark) + ); color: white; font-size: 14px; font-weight: 500; @@ -544,7 +565,11 @@ input:checked + .slider::before { .breakdown-fill { height: 100%; - background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark)); + background: linear-gradient( + 135deg, + var(--color-primary), + var(--color-primary-dark) + ); border-radius: 4px; transition: width var(--transition-normal); } @@ -583,7 +608,11 @@ input:checked + .slider::before { font-size: 32px; font-weight: 700; margin-bottom: var(--spacing-xs); - background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark)); + background: linear-gradient( + 135deg, + var(--color-primary), + var(--color-primary-dark) + ); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; @@ -657,6 +686,261 @@ input:checked + .slider::before { color: var(--text-muted); } +/* AI Detection Section */ +.ai-privacy-banner { + display: flex; + gap: 12px; + padding: 14px 16px; + background: rgba(59, 130, 246, 0.08); + border: 1px solid rgba(59, 130, 246, 0.2); + border-radius: var(--radius); + margin-bottom: 24px; + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); +} + +.ai-privacy-banner svg { + flex-shrink: 0; + margin-top: 2px; + color: #3B82F6; +} + +.ai-privacy-banner strong { + color: var(--text-primary); +} + +.api-key-input-group { + display: flex; + align-items: center; + gap: 6px; +} + +.api-key-input-block { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 6px; +} + +.api-key-input { + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 13px; + letter-spacing: 0.5px; + min-width: 240px; +} + +.api-key-preview { + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 11px; + color: var(--text-muted); + text-align: right; +} + +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: 1px solid var(--border-color); + border-radius: var(--radius); + background: var(--bg-secondary); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); + flex-shrink: 0; +} + +.btn-icon:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.range-input { + -webkit-appearance: none; + appearance: none; + width: 160px; + height: 6px; + border-radius: 3px; + background: var(--bg-tertiary); + outline: none; + cursor: pointer; +} + +.range-input::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--primary-color, #10B981); + cursor: pointer; + border: 2px solid white; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); +} + +.range-input::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--primary-color, #10B981); + cursor: pointer; + border: 2px solid white; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); +} + +/* Model Picker */ +.model-picker { + display: flex; + align-items: center; + gap: 6px; + position: relative; +} + +.model-search-input { + min-width: 260px; + font-size: 13px; +} + +.model-fetch-btn { + padding: 7px 10px !important; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.model-fetch-btn.spinning svg { + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.model-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 40px; + z-index: 1000; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + max-height: 300px; + overflow: hidden; + display: none; + margin-top: 4px; +} + +.model-dropdown.visible { + display: flex; + flex-direction: column; +} + +.model-dropdown-list { + overflow-y: auto; + max-height: 300px; +} + +.model-option { + padding: 8px 12px; + cursor: pointer; + font-size: 13px; + color: var(--text-primary); + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + transition: background var(--transition-fast); +} + +.model-option:last-child { + border-bottom: none; +} + +.model-option:hover, +.model-option.highlighted { + background: var(--bg-tertiary); +} + +.model-option.selected { + background: rgba(16, 185, 129, 0.1); + color: var(--color-primary); +} + +.model-option-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.model-option-id { + font-size: 11px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 180px; +} + +.model-option-price { + font-size: 10px; + color: var(--text-muted); + white-space: nowrap; +} + +.model-count { + padding: 6px 12px; + font-size: 11px; + color: var(--text-muted); + border-top: 1px solid var(--border-color); + text-align: center; + flex-shrink: 0; +} + +.model-no-results { + padding: 16px 12px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +.ai-usage-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} + +.ai-stat-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + text-align: center; +} + +.ai-stat-value { + font-size: 24px; + font-weight: 700; + color: var(--text-primary); + line-height: 1.2; +} + +.ai-stat-label { + font-size: 12px; + color: var(--text-muted); + margin-top: 4px; +} + /* Responsive */ @media (max-width: 768px) { .sidebar { @@ -665,24 +949,24 @@ input:checked + .slider::before { border-right: none; border-bottom: 1px solid var(--border-color); } - + .main-content { margin-left: 0; } - + .options-container { flex-direction: column; } - + .sidebar-nav { flex-direction: row; overflow-x: auto; } - + .nav-item span { display: none; } - + .stats-overview { grid-template-columns: 1fr; } diff --git a/src/options/options.html b/src/options/options.html index 29a2b0f..f256a95 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -1,635 +1,1111 @@ - - - - AdEclipse Settings - - - -
- - - - -
- -
-

General Settings

- -
-

Protection

- -
-
- Enable AdEclipse - Master switch for all ad blocking -
- -
- -
-
- Blocking Mode - Choose how aggressively to block ads -
- + + + + AdEclipse Settings + + + +
+ + + + +
+ +
+

General Settings

+ +
+

Protection

+ +
+
+ Enable AdEclipse + Master switch for all ad blocking +
+ +
+ +
+
+ Blocking Mode + Choose how aggressively to block ads +
+ +
- -
-
- Popups & Overlays - Pop-up windows and overlay ads -
- + +
+

Block Types

+ +
+
+ Video Ads + Pre-roll, mid-roll, and post-roll video ads +
+ +
+ +
+
+ Banner Ads + Display ads in headers, sidebars, and content +
+ +
+ +
+
+ Sponsored Content + Native ads and paid promotions +
+ +
+ +
+
+ Popups & Overlays + Pop-up windows and overlay ads +
+ +
+ +
+
+ Trackers + Third-party tracking scripts +
+ +
+ +
+
+ Cookie Banners + GDPR and privacy consent popups +
+ +
- -
-
- Trackers - Third-party tracking scripts -
- + +
+

Appearance

+ +
+
+ Theme + Choose your preferred color scheme +
+ +
+ +
+
+ Show Badge Count + Display blocked ads count on extension icon +
+ +
- -
-
- Cookie Banners - GDPR and privacy consent popups -
- +
+ + +
+

YouTube Settings

+ +
+

Video Ads

+ +
+
+ Enable YouTube Blocking + Block all types of YouTube ads +
+ +
+ +
+
+ Auto-Skip Ads + Automatically click skip button when available +
+ +
+ +
+
+ Speed Up Unskippable Ads + Play non-skippable ads at 16x speed +
+ +
+ +
+
+ Mute Ads + Automatically mute audio during ads +
+ +
-
- -
-

Appearance

- -
-
- Theme - Choose your preferred color scheme -
- + +
+

Page Elements

+ +
+
+ Block Overlay Ads + Remove video overlay and banner ads +
+ +
+ +
+
+ Block Masthead Ads + Remove homepage banner ads +
+ +
+ +
+
+ Block Promoted Videos + Remove sponsored results in search and recommendations +
+ +
+ +
+
+ Block Merch Shelf + Remove merchandise promotions +
+ +
- -
-
- Show Badge Count - Display blocked ads count on extension icon -
- +
+ + +
+

Website Rules

+ +
+

Website Ad Blocking Mode

+

+ Choose how website ad blocking works (YouTube is always protected + when enabled above) +

+ +
+
+ Mode + Manual: only block ads on sites you add below. All Sites: + block ads everywhere. +
+ +
-
- - - -
-

YouTube Settings

- -
-

Video Ads

- -
-
- Enable YouTube Blocking - Block all types of YouTube ads -
- + +
+

Protected Sites

+

+ Sites where ads will be blocked (add domains like + ionmedia.tv) +

+ +
+
+ + +
+
    + +
+
- -
-
- Auto-Skip Ads - Automatically click skip button when available -
- + +
+

Whitelist

+

+ Sites where ads are allowed (overrides protection) +

+ +
+
+ + +
+
    + +
+
- -
-
- Speed Up Unskippable Ads - Play non-skippable ads at 16x speed -
- + +
+

Custom Selectors

+

+ Add custom CSS selectors to block specific elements on a domain +

+ +
+
+ + + +
+
    + +
+
- -
-
- Mute Ads - Automatically mute audio during ads -
- +
+ + +
+

Statistics

+ +
+
+
+ + + + +
+
+ 0 + Total Ads Blocked +
+
+ +
+
+ + + + +
+
+ 0m + Total Time Saved +
+
+ +
+
+ + + + + +
+
+ 0 MB + Total Data Saved +
+
-
- -
-

Page Elements

- -
-
- Block Overlay Ads - Remove video overlay and banner ads -
- + +
+

Breakdown by Type

+
+
+ Video Ads +
+
+
+ 0 +
+
+ Banner Ads +
+
+
+ 0 +
+
+ Network Requests +
+
+
+ 0 +
+
+ Other +
+
+
+ 0 +
+
- -
-
- Block Masthead Ads - Remove homepage banner ads -
- + +
+
- -
-
- Block Promoted Videos - Remove sponsored results in search and recommendations -
- + + + +
+

AI-Powered Ad Detection

+ +
+ + + +
+ Privacy Notice: When enabled, element metadata (tag names, CSS classes, dimensions, text snippets) is sent to your chosen AI provider for ad classification. No personal data, cookies, or browsing history is transmitted. Your API key is stored locally and never shared. +
- -
-
- Block Merch Shelf - Remove merchandise promotions -
- + +
+

Enable AI Detection

+ +
+
+ AI Ad Detection + Use LLM APIs to intelligently detect and remove all types of ads +
+ +
-
-
- -
-

Website Rules

+
+

AI Provider

-
-

Website Ad Blocking Mode

-

Choose how website ad blocking works (YouTube is always protected when enabled above)

+
+
+ Provider + Choose your LLM API provider +
+ +
-
-
- Mode - Manual: only block ads on sites you add below. All Sites: block ads everywhere. +
+
+ API Key + Your API key (stored locally, never shared) +
+
+
+ + +
+
+
- -
-
-
-

Protected Sites

-

Sites where ads will be blocked (add domains like ionmedia.tv)

+
+
+ Model + Search and select a model +
+
+ +
+
+
+ + +
+
-
-
- - + -
    - -
-
-
-
-

Whitelist

-

Sites where ads are allowed (overrides protection)

+ -
-
- - +
+
+ Test Connection + Verify your API key and provider settings +
+
-
    - -
-
-
-

Custom Selectors

-

Add custom CSS selectors to block specific elements on a domain

+
+

Scan Settings

-
-
- - - +
+
+ Scan Mode + How the AI scanner selects elements to analyze +
+
-
    - -
-
-
-
- - -
-

Statistics

- -
-
-
- - - - + +
+
+ Confidence Threshold + Minimum confidence to remove an element (70%) +
+ +
+ +
+
+ Scan on Page Load + Automatically scan when a page finishes loading +
+
-
- 0 - Total Ads Blocked + +
+
+ Continuous Scanning + Re-scan when new content is dynamically loaded +
+
-
- -
-
- - - - + +
+
+ Smooth Removal + Animate ad removal with fade and collapse +
+
-
- 0m - Total Time Saved + +
+
+ Max Elements per Batch + Number of elements sent per API request +
+
- -
-
- - - - - + +
+

Cache

+ +
+
+ Cache Duration (hours) + How long AI verdicts are cached +
+
-
- 0 MB - Total Data Saved + +
+
+ Cache Status + Loading... +
+
-
- -
-

Breakdown by Type

-
-
- Video Ads -
-
+ +
+

Usage Statistics

+ +
+
+ 0 + API Requests +
+
+ 0 + Tokens Used +
+
+ 0 + Cached Entries +
+
+ 0 + Learned Patterns
- 0
-
- Banner Ads -
-
+
+
+ + +
+

Advanced Settings

+ +
+

Performance

+ +
+
+ Lazy Load Scripts + Defer non-essential scripts for faster page load
- 0 +
-
- Network Requests -
-
+ +
+
+ Cache Blocked Elements + Remember blocked elements to improve performance
- 0 +
-
- Other -
-
+ +
+
+ Observer Debounce (ms) + Delay between DOM checks (lower = more responsive, higher = + better performance)
- 0 +
-
- -
- -
-
- - -
-

Advanced Settings

- -
-

Performance

- -
-
- Lazy Load Scripts - Defer non-essential scripts for faster page load -
- -
- -
-
- Cache Blocked Elements - Remember blocked elements to improve performance -
- -
- -
-
- Observer Debounce (ms) - Delay between DOM checks (lower = more responsive, higher = better performance) + +
+

Machine Learning (Experimental)

+ +
+
+ Enable ML Detection + Use TensorFlow.js for intelligent ad detection +
+
- -
-
- -
-

Machine Learning (Experimental)

- -
-
- Enable ML Detection - Use TensorFlow.js for intelligent ad detection -
- -
-

Note: ML detection uses more CPU/memory and may slow down browsing.

-
- -
-

Updates

- -
-
- Auto-Update Rules - Automatically fetch latest ad blocking rules -
- +

+ Note: ML detection uses more CPU/memory and may slow down + browsing. +

- -
-
- Last Update - Never + +
+

Updates

+ +
+
+ Auto-Update Rules + Automatically fetch latest ad blocking rules +
+ +
+ +
+
+ Last Update + Never +
+
- -
-
- -
-

Debug

- -
-
- Debug Mode - Enable verbose console logging -
-
-
- -
-

Import / Export

- -
- - - + +
+

Debug

+ +
+
+ Debug Mode + Enable verbose console logging +
+ +
+ +
+ + +
-
-
- - -
-

About AdEclipse

- -
-
+ + +
+

About AdEclipse

+ +
+ -
- - - - Fast - Under 50ms overhead + +

AdEclipse

+

Version 1.0.0

+ +

+ A powerful, privacy-focused ad blocker that removes ads from + YouTube and websites while respecting your data. All processing + happens locally on your device. +

+ +
+
+ + + + Privacy First - No data collection +
+
+ + + + Fast - Under 50ms overhead +
+
+ + + + + 500+ ad domains blocked +
-
- - - - - 500+ ad domains blocked + + + +
- - - - -
-
- -
+ + +
- - + + diff --git a/src/options/options.js b/src/options/options.js index f99b040..00d1ea0 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -15,6 +15,7 @@ class OptionsPage { await this.loadStats(); this.setupNavigation(); this.setupEventListeners(); + await this.loadAIProviders(); this.populateUI(); this.applyTheme(); } @@ -22,8 +23,9 @@ class OptionsPage { // Settings Management async loadSettings() { try { - const response = await chrome.runtime.sendMessage({ type: 'getSettings' }); - this.settings = response || this.getDefaultSettings(); + const response = await chrome.runtime.sendMessage({ type: 'GET_SETTINGS' }); + this.settings = this.deepMerge(this.getDefaultSettings(), response || {}); + this.normalizeSettingsShape(); } catch (error) { console.error('Failed to load settings:', error); this.settings = this.getDefaultSettings(); @@ -48,6 +50,8 @@ class OptionsPage { speedUpAds: true, muteAds: true, blockOverlays: true, + diagnosticsEnabled: true, + diagnosticsMaxEntries: 120, skipDelay: 500, speedMultiplier: 16 }, @@ -56,25 +60,44 @@ class OptionsPage { websiteMode: 'manual', customRules: [], performance: { - observerDebounce: 100, - enablePerformanceMode: false + lazyLoad: true, + cacheEnabled: true, + debounceMs: 100, + useML: false }, ml: { enabled: false }, + ai: { + enabled: false, + provider: 'openai', + apiKey: '', + model: '', + customBaseUrl: '', + customModelName: '', + confidenceThreshold: 0.7, + scanMode: 'smart', + maxElementsPerBatch: 30, + cacheDurationHours: 24, + scanOnLoad: true, + continuousScan: true, + smoothRemoval: true, + showAiBadge: true, + usageStats: { totalTokens: 0, totalRequests: 0 } + }, updates: { autoUpdate: true }, showNotifications: true, - debug: false + debugMode: false }; } async saveSettings() { try { await chrome.runtime.sendMessage({ - type: 'saveSettings', - settings: this.settings + type: 'UPDATE_SETTINGS', + data: this.settings }); this.showToast('Settings saved'); } catch (error) { @@ -85,7 +108,7 @@ class OptionsPage { async loadStats() { try { - const response = await chrome.runtime.sendMessage({ type: 'getStats' }); + const response = await chrome.runtime.sendMessage({ type: 'GET_STATS' }); this.stats = response || { blocked: 0, session: { blocked: 0 }, today: { blocked: 0 } }; } catch (error) { console.error('Failed to load stats:', error); @@ -111,10 +134,12 @@ class OptionsPage { sections.forEach(section => section.classList.remove('active')); document.getElementById(target)?.classList.add('active'); - // Update stats if switching to stats section if (target === 'stats') { this.updateStatsUI(); } + if (target === 'ai') { + this.updateAIUsageStats(); + } }); }); } @@ -125,7 +150,7 @@ class OptionsPage { this.bindToggle('enableProtection', 'enabled'); this.bindSelect('blockingMode', 'blockingMode'); this.bindSelect('theme', 'theme'); - + // Block Types this.bindToggle('blockVideoAds', ['blockTypes', 'videoAds']); this.bindToggle('blockOverlayAds', ['blockTypes', 'overlayAds']); @@ -143,11 +168,12 @@ class OptionsPage { this.bindSelect('speedMultiplier', ['youtube', 'speedMultiplier']); // Advanced Settings - this.bindNumber('observerDebounce', ['performance', 'observerDebounce']); - this.bindToggle('performanceMode', ['performance', 'enablePerformanceMode']); - this.bindToggle('enableML', ['ml', 'enabled']); + this.bindToggle('lazyLoad', ['performance', 'lazyLoad']); + this.bindToggle('cacheEnabled', ['performance', 'cacheEnabled']); + this.bindNumber('debounceMs', ['performance', 'debounceMs']); + this.bindToggle('useML', ['performance', 'useML']); this.bindToggle('autoUpdate', ['updates', 'autoUpdate']); - this.bindToggle('debugMode', 'debug'); + this.bindToggle('debugMode', 'debugMode'); // Whitelist Management this.setupWhitelistEditor(); @@ -161,11 +187,16 @@ class OptionsPage { // Custom Rules Management this.setupCustomRulesEditor(); + // AI Detection settings + this.setupAISettings(); + // Import/Export document.getElementById('exportSettings')?.addEventListener('click', () => this.exportSettings()); + document.getElementById('exportDiagnostics')?.addEventListener('click', () => this.exportDiagnostics()); document.getElementById('importSettings')?.addEventListener('click', () => this.showImportDialog()); document.getElementById('resetSettings')?.addEventListener('click', () => this.resetSettings()); document.getElementById('clearStats')?.addEventListener('click', () => this.clearStats()); + document.getElementById('clearDiagnostics')?.addEventListener('click', () => this.clearDiagnostics()); // Theme changes document.getElementById('theme')?.addEventListener('change', () => { @@ -219,7 +250,7 @@ class OptionsPage { this.settings[path] = value; return; } - + let obj = this.settings; for (let i = 0; i < path.length - 1; i++) { if (!obj[path[i]]) obj[path[i]] = {}; @@ -252,11 +283,12 @@ class OptionsPage { this.setSelectValue('speedMultiplier', String(this.settings.youtube?.speedMultiplier || 16)); // Advanced - this.setNumberValue('observerDebounce', this.settings.performance?.observerDebounce); - this.setToggleValue('performanceMode', this.settings.performance?.enablePerformanceMode); - this.setToggleValue('enableML', this.settings.ml?.enabled); + this.setToggleValue('lazyLoad', this.settings.performance?.lazyLoad); + this.setToggleValue('cacheEnabled', this.settings.performance?.cacheEnabled); + this.setNumberValue('debounceMs', this.settings.performance?.debounceMs); + this.setToggleValue('useML', this.settings.performance?.useML); this.setToggleValue('autoUpdate', this.settings.updates?.autoUpdate); - this.setToggleValue('debugMode', this.settings.debug); + this.setToggleValue('debugMode', this.settings.debugMode); // Whitelist this.renderWhitelist(); @@ -269,6 +301,9 @@ class OptionsPage { // Custom Rules this.renderCustomRules(); + // AI Detection + this.populateAIUI(); + // Stats this.updateStatsUI(); } @@ -307,22 +342,22 @@ class OptionsPage { addWhitelistEntry() { const input = document.getElementById('whitelistInput'); const site = input?.value.trim(); - + if (!site) return; - + // Normalize domain let domain = site.replace(/^(https?:\/\/)?(www\.)?/, '').split('/')[0]; - + if (!this.settings.whitelist) { this.settings.whitelist = []; } - + if (!this.settings.whitelist.includes(domain)) { this.settings.whitelist.push(domain); this.saveSettings(); this.renderWhitelist(); } - + input.value = ''; } @@ -336,8 +371,8 @@ class OptionsPage { const list = document.getElementById('whitelistList'); if (!list) return; - const whitelist = this.settings.whitelist || []; - + const whitelist = Array.isArray(this.settings.whitelist) ? this.settings.whitelist : []; + if (whitelist.length === 0) { list.innerHTML = '
  • No sites whitelisted
  • '; return; @@ -429,7 +464,7 @@ class OptionsPage { const list = document.getElementById('blacklistItems'); if (!list) return; - const blacklist = this.settings.blacklist || []; + const blacklist = Array.isArray(this.settings.blacklist) ? this.settings.blacklist : []; if (blacklist.length === 0) { list.innerHTML = '
  • No sites added yet
  • '; @@ -475,10 +510,10 @@ class OptionsPage { addCustomRule() { const domainInput = document.getElementById('ruleDomain'); const selectorInput = document.getElementById('ruleSelector'); - + const domain = domainInput?.value.trim(); const selector = selectorInput?.value.trim(); - + if (!domain || !selector) { this.showToast('Please enter both domain and selector', 'error'); return; @@ -515,7 +550,7 @@ class OptionsPage { if (!list) return; const rules = this.settings.customRules || []; - + if (rules.length === 0) { list.innerHTML = '
  • No custom rules
  • '; return; @@ -540,15 +575,540 @@ class OptionsPage { }); } + // AI Detection settings + setupAISettings() { + this.aiProviders = []; + this.aiModels = []; + this.highlightedModelIndex = -1; + + document.getElementById('aiEnabled')?.addEventListener('change', (e) => { + if (!this.settings.ai) this.settings.ai = {}; + this.settings.ai.enabled = e.target.checked; + this.updateAIVisibility(); + this.saveSettings(); + }); + + document.getElementById('aiProvider')?.addEventListener('change', (e) => { + this.settings.ai.provider = e.target.value; + this.settings.ai.model = ''; + document.getElementById('aiModelSearch').value = ''; + document.getElementById('aiModel').value = ''; + this.updateCustomProviderVisibility(); + this.saveSettings(); + this.fetchAndPopulateModels(e.target.value); + }); + + document.getElementById('aiApiKey')?.addEventListener('input', (e) => { + this.settings.ai.apiKey = e.target.value; + this.updateApiKeyPreview(); + this.scheduleAISave(); + }); + document.getElementById('aiApiKey')?.addEventListener('change', (e) => { + this.settings.ai.apiKey = e.target.value; + this.updateApiKeyPreview(); + this.flushAISave(); + }); + + document.getElementById('aiCustomBaseUrl')?.addEventListener('input', (e) => { + this.settings.ai.customBaseUrl = e.target.value; + this.scheduleAISave(); + }); + document.getElementById('aiCustomBaseUrl')?.addEventListener('change', (e) => { + this.settings.ai.customBaseUrl = e.target.value; + this.flushAISave(); + }); + + document.getElementById('aiCustomModelName')?.addEventListener('input', (e) => { + this.settings.ai.customModelName = e.target.value; + this.settings.ai.model = e.target.value; + this.scheduleAISave(); + }); + document.getElementById('aiCustomModelName')?.addEventListener('change', (e) => { + this.settings.ai.customModelName = e.target.value; + this.settings.ai.model = e.target.value; + this.flushAISave(); + }); + + document.getElementById('aiScanMode')?.addEventListener('change', (e) => { + this.settings.ai.scanMode = e.target.value; + this.saveSettings(); + }); + + document.getElementById('aiConfidenceThreshold')?.addEventListener('input', (e) => { + const val = parseInt(e.target.value, 10); + document.getElementById('aiConfidenceValue').textContent = val; + this.settings.ai.confidenceThreshold = val / 100; + this.saveSettings(); + }); + + document.getElementById('aiScanOnLoad')?.addEventListener('change', (e) => { + this.settings.ai.scanOnLoad = e.target.checked; + this.saveSettings(); + }); + + document.getElementById('aiContinuousScan')?.addEventListener('change', (e) => { + this.settings.ai.continuousScan = e.target.checked; + this.saveSettings(); + }); + + document.getElementById('aiSmoothRemoval')?.addEventListener('change', (e) => { + this.settings.ai.smoothRemoval = e.target.checked; + this.saveSettings(); + }); + + document.getElementById('aiMaxBatch')?.addEventListener('change', (e) => { + const val = parseInt(e.target.value, 10); + if (!isNaN(val) && val >= 5 && val <= 60) { + this.settings.ai.maxElementsPerBatch = val; + this.saveSettings(); + } + }); + + document.getElementById('aiCacheDuration')?.addEventListener('change', (e) => { + const val = parseInt(e.target.value, 10); + if (!isNaN(val) && val >= 1 && val <= 168) { + this.settings.ai.cacheDurationHours = val; + this.saveSettings(); + } + }); + + document.getElementById('toggleApiKeyVisibility')?.addEventListener('click', () => { + const input = document.getElementById('aiApiKey'); + if (input) { + input.type = input.type === 'password' ? 'text' : 'password'; + } + }); + + document.getElementById('aiTestConnection')?.addEventListener('click', () => this.testAIConnection()); + document.getElementById('aiClearCache')?.addEventListener('click', () => this.clearAICache()); + document.getElementById('aiRefreshModels')?.addEventListener('click', () => { + this.fetchAndPopulateModels(this.settings.ai?.provider || 'openai', true); + }); + + this.setupModelPicker(); + window.addEventListener('beforeunload', () => this.flushAISave()); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') this.flushAISave(); + }); + } + + scheduleAISave() { + clearTimeout(this._aiSaveTimer); + this._aiSaveTimer = setTimeout(() => this.saveSettings(), 180); + } + + flushAISave() { + clearTimeout(this._aiSaveTimer); + this.saveSettings(); + } + + setupModelPicker() { + const searchInput = document.getElementById('aiModelSearch'); + const dropdown = document.getElementById('aiModelDropdown'); + if (!searchInput || !dropdown) return; + + searchInput.addEventListener('focus', () => { + this.renderModelDropdown(searchInput.value); + dropdown.classList.add('visible'); + }); + + searchInput.addEventListener('input', () => { + this.highlightedModelIndex = -1; + this.renderModelDropdown(searchInput.value); + dropdown.classList.add('visible'); + }); + + searchInput.addEventListener('keydown', (e) => { + const items = dropdown.querySelectorAll('.model-option'); + if (e.key === 'ArrowDown') { + e.preventDefault(); + this.highlightedModelIndex = Math.min(this.highlightedModelIndex + 1, items.length - 1); + this.updateModelHighlight(items); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + this.highlightedModelIndex = Math.max(this.highlightedModelIndex - 1, 0); + this.updateModelHighlight(items); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (this.highlightedModelIndex >= 0 && items[this.highlightedModelIndex]) { + items[this.highlightedModelIndex].click(); + } + } else if (e.key === 'Escape') { + dropdown.classList.remove('visible'); + searchInput.blur(); + } + }); + + document.addEventListener('click', (e) => { + if (!e.target.closest('#aiModelPicker')) { + dropdown.classList.remove('visible'); + } + }); + } + + updateModelHighlight(items) { + items.forEach((item, i) => { + item.classList.toggle('highlighted', i === this.highlightedModelIndex); + }); + if (this.highlightedModelIndex >= 0 && items[this.highlightedModelIndex]) { + items[this.highlightedModelIndex].scrollIntoView({ block: 'nearest' }); + } + } + + renderModelDropdown(query) { + const listEl = document.getElementById('aiModelList'); + const dropdown = document.getElementById('aiModelDropdown'); + if (!listEl) return; + + const q = (query || '').toLowerCase().trim(); + const filtered = q + ? this.aiModels.filter(m => + m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)) + : this.aiModels; + + if (filtered.length === 0) { + listEl.innerHTML = '
    No models found
    '; + const countEl = dropdown.querySelector('.model-count'); + if (countEl) countEl.remove(); + return; + } + + const displayModels = filtered.slice(0, 100); + const currentModel = this.settings.ai?.model || ''; + + listEl.innerHTML = displayModels.map((m, i) => { + const isSelected = m.id === currentModel; + let priceHtml = ''; + if (m.pricing?.prompt) { + const costPer1M = (parseFloat(m.pricing.prompt) * 1000000).toFixed(2); + priceHtml = `$${costPer1M}/M`; + } + return `
    + ${this.escapeHtml(m.name)} + ${m.name !== m.id ? `${this.escapeHtml(m.id)}` : ''} + ${priceHtml} +
    `; + }).join(''); + + let countEl = dropdown.querySelector('.model-count'); + if (!countEl) { + countEl = document.createElement('div'); + countEl.className = 'model-count'; + dropdown.appendChild(countEl); + } + countEl.textContent = `${filtered.length} model${filtered.length !== 1 ? 's' : ''} available`; + + listEl.querySelectorAll('.model-option').forEach(opt => { + opt.addEventListener('click', () => { + const modelId = opt.dataset.modelId; + const modelName = opt.dataset.modelName; + document.getElementById('aiModel').value = modelId; + document.getElementById('aiModelSearch').value = modelName; + this.settings.ai.model = modelId; + this.saveSettings(); + dropdown.classList.remove('visible'); + }); + }); + } + + async loadAIProviders() { + try { + const response = await chrome.runtime.sendMessage({ type: 'AI_GET_PROVIDERS' }); + if (response?.providers) { + this.aiProviders = response.providers; + } + } catch (e) { + console.error('Failed to load AI providers:', e); + } + } + + async fetchAndPopulateModels(providerKey, forceRefresh = false) { + const btn = document.getElementById('aiRefreshModels'); + const searchInput = document.getElementById('aiModelSearch'); + + if (providerKey === 'custom') { + this.aiModels = []; + if (searchInput) searchInput.value = this.settings.ai?.customModelName || ''; + return; + } + + if (btn) btn.classList.add('spinning'); + + try { + const apiKey = this.settings.ai?.apiKey || ''; + const response = await chrome.runtime.sendMessage({ + type: 'AI_FETCH_MODELS', + data: { provider: providerKey, apiKey } + }); + + if (response?.models?.length > 0) { + this.aiModels = response.models; + } else { + const provider = this.aiProviders.find(p => p.id === providerKey); + this.aiModels = provider?.models || []; + } + } catch (e) { + const provider = this.aiProviders.find(p => p.id === providerKey); + this.aiModels = provider?.models || []; + } finally { + if (btn) btn.classList.remove('spinning'); + } + + const savedModel = this.settings.ai?.model; + if (searchInput) { + if (savedModel) { + const match = this.aiModels.find(m => m.id === savedModel); + searchInput.value = match ? match.name : savedModel; + } else { + searchInput.value = ''; + } + } + } + + updateCustomProviderVisibility() { + const isCustom = this.settings.ai?.provider === 'custom'; + const urlRow = document.getElementById('aiCustomUrlRow'); + const modelRow = document.getElementById('aiCustomModelRow'); + if (urlRow) urlRow.style.display = isCustom ? '' : 'none'; + if (modelRow) modelRow.style.display = isCustom ? '' : 'none'; + } + + updateAIVisibility() { + const enabled = this.settings.ai?.enabled; + const groups = document.querySelectorAll('#aiProviderGroup, #aiScanGroup'); + groups.forEach(g => { + g.style.opacity = enabled ? '1' : '0.5'; + g.style.pointerEvents = enabled ? '' : 'none'; + }); + } + + async testAIConnection() { + const statusEl = document.getElementById('aiConnectionStatus'); + const btn = document.getElementById('aiTestConnection'); + if (!statusEl || !btn) return; + + btn.disabled = true; + btn.textContent = 'Testing...'; + statusEl.textContent = 'Connecting...'; + statusEl.style.color = ''; + + try { + const response = await chrome.runtime.sendMessage({ + type: 'AI_TEST_CONNECTION', + data: { + provider: this.settings.ai?.provider || 'openai', + apiKey: this.settings.ai?.apiKey || '', + model: this.settings.ai?.model || '', + customBaseUrl: this.settings.ai?.customBaseUrl || '' + } + }); + + if (response?.success) { + statusEl.textContent = `Connected to ${response.model || response.provider}`; + statusEl.style.color = 'var(--success, #10B981)'; + } else { + statusEl.textContent = `Failed: ${response?.error || 'Unknown error'}`; + statusEl.style.color = 'var(--danger, #EF4444)'; + } + } catch (error) { + statusEl.textContent = `Error: ${error.message}`; + statusEl.style.color = 'var(--danger, #EF4444)'; + } finally { + btn.disabled = false; + btn.textContent = 'Test'; + } + } + + async clearAICache() { + try { + await chrome.runtime.sendMessage({ type: 'AI_CLEAR_CACHE' }); + this.showToast('AI cache cleared'); + this.updateAIUsageStats(); + } catch (error) { + this.showToast('Failed to clear cache', 'error'); + } + } + + async updateAIUsageStats() { + try { + const response = await chrome.runtime.sendMessage({ type: 'AI_GET_USAGE' }); + if (response) { + const { usage, cache } = response; + const reqEl = document.getElementById('aiTotalRequests'); + const tokEl = document.getElementById('aiTotalTokens'); + const cacheEl = document.getElementById('aiCacheHits'); + const patternEl = document.getElementById('aiPatterns'); + + if (reqEl) reqEl.textContent = this.formatNumber(usage?.totalRequests || 0); + if (tokEl) tokEl.textContent = this.formatNumber(usage?.totalTokens || 0); + if (cacheEl) cacheEl.textContent = this.formatNumber(cache?.memoryCacheSize || 0); + if (patternEl) patternEl.textContent = this.formatNumber(cache?.patternCacheSize || 0); + + const statusEl = document.getElementById('aiCacheStatus'); + if (statusEl) { + statusEl.textContent = `${cache?.memoryCacheSize || 0} cached entries, ${cache?.patternCacheSize || 0} learned patterns`; + } + } + } catch (e) { + // AI may not be initialized + } + } + + async populateAIUI() { + const ai = this.settings.ai || {}; + + this.setToggleValue('aiEnabled', ai.enabled); + const normalizedProvider = ai.provider || 'openai'; + this.setSelectValue('aiProvider', normalizedProvider); + + const apiKeyInput = document.getElementById('aiApiKey'); + if (apiKeyInput) apiKeyInput.value = ai.apiKey || ''; + this.updateApiKeyPreview(); + + const hiddenModelInput = document.getElementById('aiModel'); + if (hiddenModelInput) hiddenModelInput.value = ai.model || ''; + + const customUrl = document.getElementById('aiCustomBaseUrl'); + if (customUrl) customUrl.value = ai.customBaseUrl || ''; + const customModel = document.getElementById('aiCustomModelName'); + if (customModel) customModel.value = ai.customModelName || ''; + + this.updateCustomProviderVisibility(); + + this.setSelectValue('aiScanMode', ai.scanMode || 'smart'); + + const threshold = Math.round((ai.confidenceThreshold || 0.7) * 100); + const rangeEl = document.getElementById('aiConfidenceThreshold'); + if (rangeEl) rangeEl.value = threshold; + const valEl = document.getElementById('aiConfidenceValue'); + if (valEl) valEl.textContent = threshold; + + this.setToggleValue('aiScanOnLoad', ai.scanOnLoad !== false); + this.setToggleValue('aiContinuousScan', ai.continuousScan !== false); + this.setToggleValue('aiSmoothRemoval', ai.smoothRemoval !== false); + + const batchEl = document.getElementById('aiMaxBatch'); + if (batchEl) batchEl.value = ai.maxElementsPerBatch || 30; + const cacheEl = document.getElementById('aiCacheDuration'); + if (cacheEl) cacheEl.value = ai.cacheDurationHours || 24; + + this.updateAIVisibility(); + this.updateAIUsageStats(); + + const providerKey = normalizedProvider; + if (providerKey !== 'custom') { + await this.fetchAndPopulateModels(providerKey); + } else { + const searchInput = document.getElementById('aiModelSearch'); + if (searchInput) searchInput.value = ai.customModelName || ai.model || ''; + } + } + + deepMerge(target, source) { + if (!source || typeof source !== 'object') return target; + const output = { ...target }; + + for (const key of Object.keys(source)) { + const srcVal = source[key]; + const tgtVal = output[key]; + if ( + srcVal && + typeof srcVal === 'object' && + !Array.isArray(srcVal) && + tgtVal && + typeof tgtVal === 'object' && + !Array.isArray(tgtVal) + ) { + output[key] = this.deepMerge(tgtVal, srcVal); + } else { + output[key] = srcVal; + } + } + + return output; + } + + normalizeSettingsShape() { + if (!this.settings || typeof this.settings !== 'object') { + this.settings = this.getDefaultSettings(); + return; + } + + // Legacy installs or malformed imports may store these as objects/strings. + if (!Array.isArray(this.settings.whitelist)) { + this.settings.whitelist = this.toArrayOrEmpty(this.settings.whitelist); + } + if (!Array.isArray(this.settings.blacklist)) { + this.settings.blacklist = this.toArrayOrEmpty(this.settings.blacklist); + } + if (!Array.isArray(this.settings.customRules)) { + this.settings.customRules = this.toArrayOrEmpty(this.settings.customRules); + } + + if (!this.settings.ai || typeof this.settings.ai !== 'object') { + this.settings.ai = this.getDefaultSettings().ai; + } + if (typeof this.settings.ai.provider !== 'string' || !this.settings.ai.provider) { + this.settings.ai.provider = 'openai'; + } + if (typeof this.settings.ai.apiKey !== 'string') { + this.settings.ai.apiKey = ''; + } + if (typeof this.settings.ai.model !== 'string') { + this.settings.ai.model = ''; + } + if (typeof this.settings.ai.customBaseUrl !== 'string') { + this.settings.ai.customBaseUrl = ''; + } + if (typeof this.settings.ai.customModelName !== 'string') { + this.settings.ai.customModelName = ''; + } + + if (typeof this.settings.debugMode !== 'boolean') { + this.settings.debugMode = !!this.settings.debug; + } + } + + toArrayOrEmpty(value) { + if (Array.isArray(value)) return value; + if (!value) return []; + if (typeof value === 'string') return [value]; + if (typeof value === 'object') { + return Object.values(value).filter(v => typeof v === 'string'); + } + return []; + } + + updateApiKeyPreview() { + const preview = document.getElementById('aiApiKeyPreview'); + if (!preview) return; + const key = this.settings?.ai?.apiKey || ''; + if (!key) { + preview.textContent = 'No API key saved'; + return; + } + preview.textContent = `Saved key: ${this.maskApiKey(key)}`; + } + + maskApiKey(key) { + if (!key) return ''; + if (key.length <= 10) { + return `${key.slice(0, 3)}...${key.slice(-2)}`; + } + const prefix = key.slice(0, 12); + const suffix = key.slice(-3); + return `${prefix}...${suffix}`; + } + // Stats UI async updateStatsUI() { await this.loadStats(); - + // Update stats elements with null checks const totalBlockedEl = document.getElementById('totalBlocked'); const totalTimeEl = document.getElementById('totalTime'); const totalDataEl = document.getElementById('totalData'); - + if (totalBlockedEl) { totalBlockedEl.textContent = this.formatNumber(this.stats.blocked || 0); } @@ -595,9 +1155,9 @@ class OptionsPage { applyTheme() { const theme = this.settings.theme || 'system'; const html = document.documentElement; - + html.classList.remove('light', 'dark'); - + if (theme === 'dark') { html.classList.add('dark'); } else if (theme === 'light') { @@ -615,31 +1175,49 @@ class OptionsPage { stats: this.stats }; + this.downloadJson(data, `adeclipse-settings-${Date.now()}.json`); + this.showToast('Settings exported'); + } + + downloadJson(data, fileName) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); - + const a = document.createElement('a'); a.href = url; - a.download = `adeclipse-settings-${Date.now()}.json`; + a.download = fileName; a.click(); - + URL.revokeObjectURL(url); - this.showToast('Settings exported'); + } + + async exportDiagnostics() { + try { + const diagnostics = await chrome.runtime.sendMessage({ + type: 'EXPORT_YOUTUBE_DIAGNOSTICS' + }); + + this.downloadJson(diagnostics, `adeclipse-youtube-diagnostics-${Date.now()}.json`); + this.showToast('YouTube diagnostics exported'); + } catch (error) { + console.error('Failed to export diagnostics:', error); + this.showToast('Failed to export diagnostics', 'error'); + } } showImportDialog() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; - + input.addEventListener('change', async (e) => { const file = e.target.files?.[0]; if (!file) return; - + try { const text = await file.text(); const data = JSON.parse(text); - + if (data.settings) { this.settings = { ...this.getDefaultSettings(), ...data.settings }; await this.saveSettings(); @@ -653,7 +1231,7 @@ class OptionsPage { this.showToast('Failed to import settings', 'error'); } }); - + input.click(); } @@ -674,7 +1252,7 @@ class OptionsPage { } try { - await chrome.runtime.sendMessage({ type: 'clearStats' }); + await chrome.runtime.sendMessage({ type: 'RESET_STATS' }); await this.loadStats(); this.updateStatsUI(); this.showToast('Statistics cleared'); @@ -684,6 +1262,20 @@ class OptionsPage { } } + async clearDiagnostics() { + if (!confirm('Are you sure you want to clear all YouTube diagnostics?')) { + return; + } + + try { + await chrome.runtime.sendMessage({ type: 'CLEAR_YOUTUBE_DIAGNOSTICS' }); + this.showToast('YouTube diagnostics cleared'); + } catch (error) { + console.error('Failed to clear diagnostics:', error); + this.showToast('Failed to clear diagnostics', 'error'); + } + } + // Utilities formatNumber(num) { if (num >= 1000000) { @@ -733,9 +1325,9 @@ class OptionsPage { z-index: 10000; animation: slideIn 0.3s ease; `; - + document.body.appendChild(toast); - + setTimeout(() => { toast.style.animation = 'slideOut 0.3s ease'; setTimeout(() => toast.remove(), 300); diff --git a/src/popup/popup.css b/src/popup/popup.css index a3d806a..61c86e4 100644 --- a/src/popup/popup.css +++ b/src/popup/popup.css @@ -2,25 +2,25 @@ :root { /* Colors */ - --color-primary: #10B981; + --color-primary: #10b981; --color-primary-dark: #059669; - --color-primary-light: #34D399; - --color-danger: #EF4444; - --color-warning: #F59E0B; - --color-info: #3B82F6; - + --color-primary-light: #34d399; + --color-danger: #ef4444; + --color-warning: #f59e0b; + --color-info: #3b82f6; + /* Light theme */ - --bg-primary: #FFFFFF; - --bg-secondary: #F9FAFB; - --bg-tertiary: #F3F4F6; + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f3f4f6; --text-primary: #111827; - --text-secondary: #6B7280; - --text-muted: #9CA3AF; - --border-color: #E5E7EB; + --text-secondary: #6b7280; + --text-muted: #9ca3af; + --border-color: #e5e7eb; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); - + /* Sizing */ --popup-width: 360px; --border-radius: 12px; @@ -30,7 +30,7 @@ --spacing-md: 12px; --spacing-lg: 16px; --spacing-xl: 24px; - + /* Transitions */ --transition-fast: 0.15s ease; --transition-normal: 0.25s ease; @@ -39,12 +39,12 @@ /* Dark theme */ @media (prefers-color-scheme: dark) { :root { - --bg-primary: #1F2937; + --bg-primary: #1f2937; --bg-secondary: #111827; --bg-tertiary: #374151; - --text-primary: #F9FAFB; - --text-secondary: #D1D5DB; - --text-muted: #9CA3AF; + --text-primary: #f9fafb; + --text-secondary: #d1d5db; + --text-muted: #9ca3af; --border-color: #374151; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3); @@ -54,24 +54,27 @@ /* Force dark mode class */ .dark { - --bg-primary: #1F2937; + --bg-primary: #1f2937; --bg-secondary: #111827; --bg-tertiary: #374151; - --text-primary: #F9FAFB; - --text-secondary: #D1D5DB; - --text-muted: #9CA3AF; + --text-primary: #f9fafb; + --text-secondary: #d1d5db; + --text-muted: #9ca3af; --border-color: #374151; } /* Reset */ -*, *::before, *::after { +*, +*::before, +*::after { box-sizing: border-box; margin: 0; padding: 0; } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; font-size: 14px; line-height: 1.5; color: var(--text-primary); @@ -117,7 +120,11 @@ body { .logo-text { font-size: 20px; font-weight: 700; - background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark)); + background: linear-gradient( + 135deg, + var(--color-primary), + var(--color-primary-dark) + ); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; @@ -222,7 +229,11 @@ body { } input:checked + .slider { - background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark)); + background: linear-gradient( + 135deg, + var(--color-primary), + var(--color-primary-dark) + ); } input:checked + .slider::before { @@ -462,7 +473,7 @@ input:checked + .slider::before { } .toggle-small::after { - content: ''; + content: ""; position: absolute; width: 16px; height: 16px; @@ -604,7 +615,11 @@ input:checked + .slider::before { width: 100%; padding: var(--spacing-md); border: none; - background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark)); + background: linear-gradient( + 135deg, + var(--color-primary), + var(--color-primary-dark) + ); color: white; font-size: 14px; font-weight: 600; @@ -620,20 +635,38 @@ input:checked + .slider::before { /* Animations */ @keyframes fadeIn { - from { opacity: 0; transform: translateY(-10px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } } .popup-container > * { animation: fadeIn var(--transition-normal) ease forwards; } -.popup-container > *:nth-child(1) { animation-delay: 0ms; } -.popup-container > *:nth-child(2) { animation-delay: 50ms; } -.popup-container > *:nth-child(3) { animation-delay: 100ms; } -.popup-container > *:nth-child(4) { animation-delay: 150ms; } -.popup-container > *:nth-child(5) { animation-delay: 200ms; } -.popup-container > *:nth-child(6) { animation-delay: 250ms; } +.popup-container > *:nth-child(1) { + animation-delay: 0ms; +} +.popup-container > *:nth-child(2) { + animation-delay: 50ms; +} +.popup-container > *:nth-child(3) { + animation-delay: 100ms; +} +.popup-container > *:nth-child(4) { + animation-delay: 150ms; +} +.popup-container > *:nth-child(5) { + animation-delay: 200ms; +} +.popup-container > *:nth-child(6) { + animation-delay: 250ms; +} /* Scrollbar */ ::-webkit-scrollbar { diff --git a/src/popup/popup.html b/src/popup/popup.html index d1b0807..24af686 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -1,210 +1,283 @@ - - - - AdEclipse - - - - - + - -
    -

    Blocking Mode

    -
    - - - -
    -
    - - -
    -

    Block Types

    -
    - - - - - - -
    -
    - - -
    - - -
    -
    + +
    - -