From 654afdad09f17da2d58007d548e8e9f48190d0e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Cmit=20Tun=C3=A7?= Date: Wed, 29 Apr 2026 23:26:31 +0300 Subject: [PATCH] feat: implement LinkExtractionService using HeadlessInAppWebView to intercept and extract video stream URLs --- .../services/link_extraction_service.dart | 186 +++++++++++++++--- 1 file changed, 156 insertions(+), 30 deletions(-) diff --git a/lib/features/sender/data/services/link_extraction_service.dart b/lib/features/sender/data/services/link_extraction_service.dart index 1fc376e..ed98216 100644 --- a/lib/features/sender/data/services/link_extraction_service.dart +++ b/lib/features/sender/data/services/link_extraction_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:collection'; import 'package:flutter/foundation.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; @@ -6,57 +8,181 @@ abstract class ILinkExtractionService { } class LinkExtractionService implements ILinkExtractionService { - HeadlessInAppWebView? _headlessWebView; - + @override Future extractVideoUrl(String pageUrl) async { - // 1. Check if it's already a video link + // 1. Zaten direkt bir video linkiyse hemen döndür if (_isVideoUrl(pageUrl)) return pageUrl; - // 2. Use Headless WebView to find the video - String? foundUrl; + final completer = Completer(); + Timer? timeoutTimer; - _headlessWebView = HeadlessInAppWebView( + // HeadlessWebView'i yerel değişkene alıyoruz ki paralel çalışmalarda çakışmasın + HeadlessInAppWebView? headlessWebView; + + // Temizlik ve Bellek Yönetimi + void cleanup() { + timeoutTimer?.cancel(); + headlessWebView?.dispose(); + headlessWebView = null; + } + + // Bulunan URL'yi işleme + void handleFoundUrl(String url) { + if (!completer.isCompleted) { + debugPrint("EXTRACTION: SUCCESS! Found: $url"); + cleanup(); + completer.complete(url); + } + } + + // Zaman aşımı (15 saniye) + timeoutTimer = Timer(const Duration(seconds: 15), () { + if (!completer.isCompleted) { + debugPrint("EXTRACTION: Timeout - no video URL found"); + cleanup(); + completer.complete(null); + } + }); + + // 2. UserScript: JS Kodunu Iframe'ler dahil tüm sayfalara DOM yüklenmeden ENJEKTE EDİYORUZ + final snifferScript = UserScript( + source: ''' + (function() { + if (window.snifferInjected) return; + window.snifferInjected = true; + + console.log("SNIFFER: Injected into frame: " + window.location.href); + + function reportUrl(url) { + if (url && (url.includes('.m3u8') || url.includes('.mp4'))) { + window.flutter_inappwebview.callHandler('foundVideoUrl', url); + } + } + + // 1. Override XMLHttpRequest + var oldOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function(method, url) { + reportUrl(url); + return oldOpen.apply(this, arguments); + }; + + // 2. Override Fetch + var oldFetch = window.fetch; + window.fetch = function() { + var url = (arguments[0] instanceof Request) ? arguments[0].url : arguments[0]; + if (url) reportUrl(url.toString()); + return oldFetch.apply(this, arguments); + }; + + // 3. Oynatma Butonlarına Tıklama (Tüm iframe'lerde çalışacak) + window.clickPlayButtons = function() { + var selectors = [ + '.play-that-video', '.play-button', '.vjs-big-play-button', + '[aria-label="Play"]', '.ytp-large-play-button', '.play-icon', + '#play', '.player-play', '.jw-display-icon-container', + 'div[class*="play"]', 'button[class*="play"]', 'svg[class*="play"]' + ]; + + selectors.forEach(function(sel) { + try { + document.querySelectorAll(sel).forEach(function(el) { + if (el.offsetWidth > 0 || el.offsetHeight > 0) { + el.click(); + } + }); + } catch(e) {} + }); + + // Video etiketlerini direkt kontrol et + document.querySelectorAll('video').forEach(function(v) { + if (v.src && v.src.length > 5) reportUrl(v.src); + v.querySelectorAll('source').forEach(function(s) { + if (s.src) reportUrl(s.src); + }); + }); + }; + + // HTML içindeki linkleri Regex ile tara + var scanInterval = setInterval(function() { + var html = document.documentElement.innerHTML; + var match = html.match(/https?:\\/\\/[^"'\\s<>]+\\.(m3u8|mp4)[^"'\\s<>]*/i); + if (match) reportUrl(match[0]); + }, 1500); + + })(); + ''', + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, // Sayfa açılmadan hemen önce + forMainFrameOnly: false, // ÇOK ÖNEMLİ: Iframe'lerin içine girmesi için false olmalı! + ); + + headlessWebView = HeadlessInAppWebView( initialUrlRequest: URLRequest(url: WebUri(pageUrl)), + initialUserScripts: UnmodifiableListView([snifferScript]), // Scripti buradan veriyoruz initialSettings: InAppWebViewSettings( javaScriptEnabled: true, isInspectable: true, + mediaPlaybackRequiresUserGesture: false, // ÇOK ÖNEMLİ: Videoların tıklanmadan yüklenmesine izin ver mixedContentMode: MixedContentMode.MIXED_CONTENT_ALWAYS_ALLOW, - userAgent: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36", + supportMultipleWindows: false, + javaScriptCanOpenWindowsAutomatically: false, + useShouldInterceptRequest: true, + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", ), + onWebViewCreated: (controller) { + // JS'den gelen sinyali dinle + controller.addJavaScriptHandler(handlerName: 'foundVideoUrl', callback: (args) { + if (args.isNotEmpty) { + handleFoundUrl(args[0].toString()); + } + }); + }, onReceivedServerTrustAuthRequest: (controller, challenge) async { return ServerTrustAuthResponse(action: ServerTrustAuthResponseAction.PROCEED); }, - onConsoleMessage: (controller, consoleMessage) { - debugPrint("WEBVIEW CONSOLE: ${consoleMessage.message}"); - }, - onLoadError: (controller, url, code, message) { - debugPrint("WEBVIEW ERROR: $message ($code) for $url"); - }, - onLoadHttpError: (controller, url, statusCode, description) { - debugPrint("WEBVIEW HTTP ERROR: $description ($statusCode) for $url"); - }, - onLoadResource: (controller, resource) { - final url = resource.url.toString(); + shouldInterceptRequest: (controller, request) async { + final url = request.url.toString(); if (_isVideoUrl(url)) { - foundUrl = url; - _headlessWebView?.dispose(); + handleFoundUrl(url); + } + return null; + }, + onLoadStop: (controller, url) async { + debugPrint("EXTRACTION: Page loaded: $url"); + + // JS yüklenmiş mi diye kontrol et ve butonlara tıkla + for (int i = 0; i < 8; i++) { + if (completer.isCompleted) break; + await Future.delayed(const Duration(seconds: 1)); + if (completer.isCompleted) break; + + try { + // WebView kapanmışsa hata fırlatmasını engellemek için try-catch + await controller.evaluateJavascript(source: "if(window.clickPlayButtons) window.clickPlayButtons();"); + } catch (e) { + debugPrint("WebView disposed before javascript execution."); + } } }, ); - await _headlessWebView?.run(); - - // Wait for some time to catch resources - await Future.delayed(const Duration(seconds: 5)); - - return foundUrl; + await headlessWebView?.run(); + return completer.future; } bool _isVideoUrl(String url) { final lower = url.toLowerCase(); - return lower.contains('.m3u8') || - lower.contains('.mp4') || - lower.contains('.mkv'); + if (lower.startsWith('data:') || lower.length < 10) return false; + + // Analitik veya reklam linklerini es geç + if (lower.contains('google.com') || lower.contains('gstatic.com') || lower.contains('analytics')) return false; + + return lower.contains('.m3u8') || + lower.contains('.mp4') || + lower.contains('.mkv') || + lower.contains('/hls/') || + lower.contains('master.m3u8') || + lower.contains('index.m3u8') || + lower.contains('playlist.m3u8'); } -} +} \ No newline at end of file