feat: implement LinkExtractionService using HeadlessInAppWebView to intercept and extract video stream URLs

This commit is contained in:
Ümit Tunç
2026-04-29 23:26:31 +03:00
parent 25c312de28
commit 654afdad09
@@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart';
@@ -6,57 +8,181 @@ abstract class ILinkExtractionService {
} }
class LinkExtractionService implements ILinkExtractionService { class LinkExtractionService implements ILinkExtractionService {
HeadlessInAppWebView? _headlessWebView;
@override @override
Future<String?> extractVideoUrl(String pageUrl) async { Future<String?> 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; if (_isVideoUrl(pageUrl)) return pageUrl;
// 2. Use Headless WebView to find the video final completer = Completer<String?>();
String? foundUrl; 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)), initialUrlRequest: URLRequest(url: WebUri(pageUrl)),
initialUserScripts: UnmodifiableListView<UserScript>([snifferScript]), // Scripti buradan veriyoruz
initialSettings: InAppWebViewSettings( initialSettings: InAppWebViewSettings(
javaScriptEnabled: true, javaScriptEnabled: true,
isInspectable: true, isInspectable: true,
mediaPlaybackRequiresUserGesture: false, // ÇOK ÖNEMLİ: Videoların tıklanmadan yüklenmesine izin ver
mixedContentMode: MixedContentMode.MIXED_CONTENT_ALWAYS_ALLOW, 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 { onReceivedServerTrustAuthRequest: (controller, challenge) async {
return ServerTrustAuthResponse(action: ServerTrustAuthResponseAction.PROCEED); return ServerTrustAuthResponse(action: ServerTrustAuthResponseAction.PROCEED);
}, },
onConsoleMessage: (controller, consoleMessage) { shouldInterceptRequest: (controller, request) async {
debugPrint("WEBVIEW CONSOLE: ${consoleMessage.message}"); final url = request.url.toString();
},
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();
if (_isVideoUrl(url)) { if (_isVideoUrl(url)) {
foundUrl = url; handleFoundUrl(url);
_headlessWebView?.dispose(); }
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(); await headlessWebView?.run();
return completer.future;
// Wait for some time to catch resources
await Future.delayed(const Duration(seconds: 5));
return foundUrl;
} }
bool _isVideoUrl(String url) { bool _isVideoUrl(String url) {
final lower = url.toLowerCase(); final lower = url.toLowerCase();
return lower.contains('.m3u8') || if (lower.startsWith('data:') || lower.length < 10) return false;
lower.contains('.mp4') ||
lower.contains('.mkv'); // 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');
} }
} }