From cabe05eb03ac8aafd06b4beae54432761e5f81e2 Mon Sep 17 00:00:00 2001 From: xkeyC <39891083+xkeyC@users.noreply.github.com> Date: Sat, 8 Nov 2025 17:49:57 +0800 Subject: [PATCH] feat: web request_interceptor --- assets/request_interceptor.js | 232 ++++++++++++++++++ .../advanced_localization_ui_model.dart | 2 +- lib/ui/webview/webview.dart | 153 ++++++------ 3 files changed, 302 insertions(+), 85 deletions(-) create mode 100644 assets/request_interceptor.js diff --git a/assets/request_interceptor.js b/assets/request_interceptor.js new file mode 100644 index 0000000..3fb8f92 --- /dev/null +++ b/assets/request_interceptor.js @@ -0,0 +1,232 @@ +/// ------- Request Interceptor Script -------------- +/// 轻量级网络请求拦截器,不破坏网页正常功能 +(function() { + 'use strict'; + + if (window._sctRequestInterceptorInstalled) { + console.log('[SCToolbox] Request interceptor already installed'); + return; + } + window._sctRequestInterceptorInstalled = true; + + // 被屏蔽的域名和路径 + const blockedPatterns = [ + 'google-analytics.com', + 'www.google.com/ccm/collect', + 'www.google.com/pagead', + 'www.google.com/ads', + 'googleapis.com', + 'doubleclick.net', + 'reddit.com/rp.gif', + 'alb.reddit.com', + 'pixel-config.reddit.com', + 'conversions-config.reddit.com', + 'redditstatic.com/ads', + 'analytics.tiktok.com', + 'googletagmanager.com', + 'facebook.com', + 'facebook.net', + 'gstatic.com/firebasejs' + ]; + + // 判断 URL 是否应该被屏蔽 + const shouldBlock = (url) => { + if (!url || typeof url !== 'string') return false; + const urlLower = url.toLowerCase(); + return blockedPatterns.some(pattern => urlLower.includes(pattern.toLowerCase())); + }; + + // 记录被拦截的请求 + const logBlocked = (type, url) => { + console.log(`[SCToolbox] ❌ Blocked ${type}:`, url); + }; + + const TRANSPARENT_GIF = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + + // ============ 1. 拦截 Fetch API ============ + const originalFetch = window.fetch; + window.fetch = function(...args) { + const url = typeof args[0] === 'string' ? args[0] : args[0]?.url; + if (shouldBlock(url)) { + logBlocked('fetch', url); + return Promise.reject(new Error('Blocked by SCToolbox')); + } + return originalFetch.apply(this, args); + }; + + // ============ 2. 拦截 XMLHttpRequest ============ + const OriginalXHR = window.XMLHttpRequest; + const originalXHROpen = OriginalXHR.prototype.open; + const originalXHRSend = OriginalXHR.prototype.send; + + OriginalXHR.prototype.open = function(method, url, ...rest) { + this._url = url; + if (shouldBlock(url)) { + logBlocked('XHR', url); + this._blocked = true; + } + return originalXHROpen.apply(this, [method, url, ...rest]); + }; + + OriginalXHR.prototype.send = function(...args) { + if (this._blocked) { + setTimeout(() => { + const errorEvent = new Event('error'); + this.dispatchEvent(errorEvent); + }, 0); + return; + } + return originalXHRSend.apply(this, args); + }; + + // ============ 3. 拦截 Image 元素的 src 属性 ============ + const imgSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src'); + if (imgSrcDescriptor && imgSrcDescriptor.set) { + Object.defineProperty(HTMLImageElement.prototype, 'src', { + get: imgSrcDescriptor.get, + set: function(value) { + if (shouldBlock(value)) { + logBlocked('IMG.src', value); + // 设置为透明 GIF,避免请求 + imgSrcDescriptor.set.call(this, TRANSPARENT_GIF); + this.style.cssText += 'display:none !important;width:0;height:0;'; + return; + } + return imgSrcDescriptor.set.call(this, value); + }, + configurable: true, + enumerable: true + }); + } + + // ============ 3.5. 拦截 Script 元素的 src 属性 ============ + const scriptSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src'); + if (scriptSrcDescriptor && scriptSrcDescriptor.set) { + Object.defineProperty(HTMLScriptElement.prototype, 'src', { + get: scriptSrcDescriptor.get, + set: function(value) { + if (shouldBlock(value)) { + logBlocked('SCRIPT.src', value); + // 阻止加载,不设置 src + this.type = 'javascript/blocked'; + return; + } + return scriptSrcDescriptor.set.call(this, value); + }, + configurable: true, + enumerable: true + }); + } + + // ============ 4. 拦截 setAttribute(用于 img.setAttribute('src', ...))============ + const originalSetAttribute = Element.prototype.setAttribute; + Element.prototype.setAttribute = function(name, value) { + if (name.toLowerCase() === 'src' && this.tagName === 'IMG' && shouldBlock(value)) { + logBlocked('IMG setAttribute', value); + originalSetAttribute.call(this, name, TRANSPARENT_GIF); + this.style.cssText += 'display:none !important;width:0;height:0;'; + return; + } + if (name.toLowerCase() === 'src' && this.tagName === 'SCRIPT' && shouldBlock(value)) { + logBlocked('SCRIPT setAttribute', value); + return; // 阻止设置 + } + return originalSetAttribute.call(this, name, value); + }; + + // ============ 5. 拦截 navigator.sendBeacon ============ + if (navigator.sendBeacon) { + const originalSendBeacon = navigator.sendBeacon.bind(navigator); + navigator.sendBeacon = function(url, data) { + if (shouldBlock(url)) { + logBlocked('sendBeacon', url); + return true; // 假装成功 + } + return originalSendBeacon(url, data); + }; + } + + // ============ 6. 使用 MutationObserver 监听动态添加的元素 ============ + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType !== 1) return; // 只处理元素节点 + + try { + // 检查 IMG 元素 + if (node.tagName === 'IMG') { + const src = node.getAttribute('src') || node.src; + if (src && shouldBlock(src)) { + logBlocked('Dynamic IMG', src); + node.src = TRANSPARENT_GIF; + node.style.cssText += 'display:none !important;width:0;height:0;'; + } + } + // 检查 SCRIPT 元素 + else if (node.tagName === 'SCRIPT') { + const src = node.getAttribute('src'); + if (src && shouldBlock(src)) { + logBlocked('Dynamic SCRIPT', src); + node.type = 'javascript/blocked'; + node.removeAttribute('src'); + } + } + // 检查 IFRAME 元素 + else if (node.tagName === 'IFRAME') { + const src = node.getAttribute('src'); + if (src && shouldBlock(src)) { + logBlocked('Dynamic IFRAME', src); + node.src = 'about:blank'; + node.style.cssText += 'display:none !important;'; + } + } + + // 递归检查子元素 + if (node.querySelectorAll) { + node.querySelectorAll('img').forEach(img => { + const src = img.getAttribute('src') || img.src; + if (src && shouldBlock(src)) { + logBlocked('Child IMG', src); + img.src = TRANSPARENT_GIF; + img.style.cssText += 'display:none !important;width:0;height:0;'; + } + }); + + node.querySelectorAll('script[src]').forEach(script => { + const src = script.getAttribute('src'); + if (src && shouldBlock(src)) { + logBlocked('Child SCRIPT', src); + script.type = 'javascript/blocked'; + script.removeAttribute('src'); + } + }); + } + } catch (e) { + // 忽略错误 + } + }); + }); + }); + + // 延迟启动 observer,等待页面初始化完成 + const startObserver = () => { + if (document.body) { + observer.observe(document.documentElement, { + childList: true, + subtree: true + }); + console.log('[SCToolbox] ✅ MutationObserver started'); + } else { + setTimeout(startObserver, 50); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', startObserver); + } else { + startObserver(); + } + + console.log('[SCToolbox] ✅ Request interceptor installed'); + console.log('[SCToolbox] 🛡️ Blocking', blockedPatterns.length, 'patterns'); +})(); diff --git a/lib/ui/home/localization/advanced_localization_ui_model.dart b/lib/ui/home/localization/advanced_localization_ui_model.dart index c67eecd..a5fb556 100644 --- a/lib/ui/home/localization/advanced_localization_ui_model.dart +++ b/lib/ui/home/localization/advanced_localization_ui_model.dart @@ -278,8 +278,8 @@ class AdvancedLocalizationUIModel extends _$AdvancedLocalizationUIModel { state = state.copyWith(classMap: classMap); } - // ignore: avoid_build_context_in_providers Future doInstall( + // ignore: avoid_build_context_in_providers BuildContext context, { bool isEnableCommunityInputMethod = false, bool isEnableVehicleSorting = false, diff --git a/lib/ui/webview/webview.dart b/lib/ui/webview/webview.dart index a9b2c1a..edbe2f0 100644 --- a/lib/ui/webview/webview.dart +++ b/lib/ui/webview/webview.dart @@ -26,8 +26,7 @@ class WebViewModel { bool get isClosed => _isClosed; - WebViewModel(this.context, - {this.loginMode = false, this.loginCallback, this.loginChannel = "LIVE"}); + WebViewModel(this.context, {this.loginMode = false, this.loginCallback, this.loginChannel = "LIVE"}); String url = ""; bool canGoBack = false; @@ -35,6 +34,7 @@ class WebViewModel { final localizationResource = {}; var localizationScript = ""; + var requestInterceptorScript = ""; bool enableCapture = false; @@ -51,20 +51,22 @@ class WebViewModel { final RsiLoginCallback? loginCallback; - Future initWebView( - {String title = "", - required String applicationSupportDir, - required AppVersionData appVersionData}) async { + Future initWebView({ + String title = "", + required String applicationSupportDir, + required AppVersionData appVersionData, + }) async { try { final userBox = await Hive.openBox("app_conf"); - isEnableToolSiteMirrors = - userBox.get("isEnableToolSiteMirrors", defaultValue: false); + isEnableToolSiteMirrors = userBox.get("isEnableToolSiteMirrors", defaultValue: false); webview = await WebviewWindow.create( - configuration: CreateConfiguration( - windowWidth: loginMode ? 960 : 1920, - windowHeight: loginMode ? 720 : 1080, - userDataFolderWindows: "$applicationSupportDir/webview_data", - title: Platform.isMacOS ? "" : title)); + configuration: CreateConfiguration( + windowWidth: loginMode ? 960 : 1920, + windowHeight: loginMode ? 720 : 1080, + userDataFolderWindows: "$applicationSupportDir/webview_data", + title: Platform.isMacOS ? "" : title, + ), + ); // webview.openDevToolsWindow(); webview.isNavigating.addListener(() async { if (!webview.isNavigating.value && localizationResource.isNotEmpty) { @@ -74,14 +76,10 @@ class WebViewModel { final replaceWords = _getLocalizationResource("zh-CN"); const org = "https://robertsspaceindustries.com/orgs"; const citizens = "https://robertsspaceindustries.com/citizens"; - const organization = - "https://robertsspaceindustries.com/account/organization"; - const concierge = - "https://robertsspaceindustries.com/account/concierge"; - const referral = - "https://robertsspaceindustries.com/account/referral-program"; - const address = - "https://robertsspaceindustries.com/account/addresses"; + const organization = "https://robertsspaceindustries.com/account/organization"; + const concierge = "https://robertsspaceindustries.com/account/concierge"; + const referral = "https://robertsspaceindustries.com/account/referral-program"; + const address = "https://robertsspaceindustries.com/account/addresses"; const hangar = "https://robertsspaceindustries.com/account/pledges"; @@ -95,13 +93,8 @@ class WebViewModel { await Future.delayed(const Duration(milliseconds: 100)); await webview.evaluateJavaScript(localizationScript); - if (url.startsWith(org) || - url.startsWith(citizens) || - url.startsWith(organization)) { - replaceWords.add({ - "word": 'members', - "replacement": S.current.webview_localization_name_member - }); + if (url.startsWith(org) || url.startsWith(citizens) || url.startsWith(organization)) { + replaceWords.add({"word": 'members', "replacement": S.current.webview_localization_name_member}); replaceWords.addAll(_getLocalizationResource("orgs")); } @@ -111,21 +104,9 @@ class WebViewModel { if (url.startsWith(referral)) { replaceWords.addAll([ - { - "word": 'Total recruits: ', - "replacement": - S.current.webview_localization_total_invitations - }, - { - "word": 'Prospects ', - "replacement": - S.current.webview_localization_unfinished_invitations - }, - { - "word": 'Recruits', - "replacement": - S.current.webview_localization_finished_invitations - }, + {"word": 'Total recruits: ', "replacement": S.current.webview_localization_total_invitations}, + {"word": 'Prospects ', "replacement": S.current.webview_localization_unfinished_invitations}, + {"word": 'Recruits', "replacement": S.current.webview_localization_finished_invitations}, ]); } @@ -139,48 +120,43 @@ class WebViewModel { _curReplaceWords = {}; for (var element in replaceWords) { - _curReplaceWords?[element["word"] ?? ""] = - element["replacement"] ?? ""; + _curReplaceWords?[element["word"] ?? ""] = element["replacement"] ?? ""; } await webview.evaluateJavaScript("InitWebLocalization()"); await Future.delayed(const Duration(milliseconds: 100)); dPrint("update replaceWords"); await webview.evaluateJavaScript( - "WebLocalizationUpdateReplaceWords(${json.encode(replaceWords)},$enableCapture)"); + "WebLocalizationUpdateReplaceWords(${json.encode(replaceWords)},$enableCapture)", + ); /// loginMode if (loginMode) { - dPrint( - "--- do rsi login ---\n run === getRSILauncherToken(\"$loginChannel\");"); + dPrint("--- do rsi login ---\n run === getRSILauncherToken(\"$loginChannel\");"); await Future.delayed(const Duration(milliseconds: 200)); - webview.evaluateJavaScript( - "getRSILauncherToken(\"$loginChannel\");"); + webview.evaluateJavaScript("getRSILauncherToken(\"$loginChannel\");"); } - } else if (url.startsWith(await _handleMirrorsUrl( - "https://www.erkul.games", appVersionData))) { + } else if (url.startsWith(await _handleMirrorsUrl("https://www.erkul.games", appVersionData))) { dPrint("load script"); await Future.delayed(const Duration(milliseconds: 100)); await webview.evaluateJavaScript(localizationScript); dPrint("update replaceWords"); final replaceWords = _getLocalizationResource("DPS"); await webview.evaluateJavaScript( - "WebLocalizationUpdateReplaceWords(${json.encode(replaceWords)},$enableCapture)"); - } else if (url.startsWith(await _handleMirrorsUrl( - "https://uexcorp.space", appVersionData))) { + "WebLocalizationUpdateReplaceWords(${json.encode(replaceWords)},$enableCapture)", + ); + } else if (url.startsWith(await _handleMirrorsUrl("https://uexcorp.space", appVersionData))) { dPrint("load script"); await Future.delayed(const Duration(milliseconds: 100)); await webview.evaluateJavaScript(localizationScript); dPrint("update replaceWords"); final replaceWords = _getLocalizationResource("UEX"); await webview.evaluateJavaScript( - "WebLocalizationUpdateReplaceWords(${json.encode(replaceWords)},$enableCapture)"); + "WebLocalizationUpdateReplaceWords(${json.encode(replaceWords)},$enableCapture)", + ); } } }); - webview.addOnUrlRequestCallback((url) { - dPrint("OnUrlRequestCallback === $url"); - this.url = url; - }); + webview.addOnUrlRequestCallback(_onUrlRequest); webview.onClose.whenComplete(dispose); if (loginMode) { webview.addOnWebMessageReceivedCallback((messageString) { @@ -199,8 +175,7 @@ class WebViewModel { } } - Future _handleMirrorsUrl( - String url, AppVersionData appVersionData) async { + Future _handleMirrorsUrl(String url, AppVersionData appVersionData) async { var finalUrl = url; if (isEnableToolSiteMirrors) { for (var kv in appVersionData.webMirrors!.entries) { @@ -219,28 +194,28 @@ class WebViewModel { Future initLocalization(AppWebLocalizationVersionsData v) async { localizationScript = await rootBundle.loadString('assets/web_script.js'); + requestInterceptorScript = await rootBundle.loadString('assets/request_interceptor.js'); /// https://github.com/CxJuice/Uex_Chinese_Translate // get versions final hostUrl = URLConf.webTranslateHomeUrl; dPrint("AppWebLocalizationVersionsData === ${v.toJson()}"); - localizationResource["zh-CN"] = await _getJson("$hostUrl/zh-CN-rsi.json", - cacheKey: "rsi", version: v.rsi); + localizationResource["zh-CN"] = await _getJson("$hostUrl/zh-CN-rsi.json", cacheKey: "rsi", version: v.rsi); localizationResource["concierge"] = await _getJson( - "$hostUrl/concierge.json", - cacheKey: "concierge", - version: v.concierge); - localizationResource["orgs"] = - await _getJson("$hostUrl/orgs.json", cacheKey: "orgs", version: v.orgs); - localizationResource["address"] = await _getJson("$hostUrl/addresses.json", - cacheKey: "addresses", version: v.addresses); - localizationResource["hangar"] = await _getJson("$hostUrl/hangar.json", - cacheKey: "hangar", version: v.hangar); - localizationResource["UEX"] = await _getJson("$hostUrl/zh-CN-uex.json", - cacheKey: "uex", version: v.uex); - localizationResource["DPS"] = await _getJson("$hostUrl/zh-CN-dps.json", - cacheKey: "dps", version: v.dps); + "$hostUrl/concierge.json", + cacheKey: "concierge", + version: v.concierge, + ); + localizationResource["orgs"] = await _getJson("$hostUrl/orgs.json", cacheKey: "orgs", version: v.orgs); + localizationResource["address"] = await _getJson( + "$hostUrl/addresses.json", + cacheKey: "addresses", + version: v.addresses, + ); + localizationResource["hangar"] = await _getJson("$hostUrl/hangar.json", cacheKey: "hangar", version: v.hangar); + localizationResource["UEX"] = await _getJson("$hostUrl/zh-CN-uex.json", cacheKey: "uex", version: v.uex); + localizationResource["DPS"] = await _getJson("$hostUrl/zh-CN-dps.json", cacheKey: "dps", version: v.dps); } List> _getLocalizationResource(String key) { @@ -254,15 +229,13 @@ class WebViewModel { .toLowerCase() .replaceAll(RegExp("/\xa0/g"), ' ') .replaceAll(RegExp("/s{2,}/g"), ' '); - localizations - .add({"word": k, "replacement": element.value.toString().trim()}); + localizations.add({"word": k, "replacement": element.value.toString().trim()}); } } return localizations; } - Future _getJson(String url, - {String cacheKey = "", String? version}) async { + Future _getJson(String url, {String cacheKey = "", String? version}) async { final box = await Hive.openBox("web_localization_cache_data"); if (cacheKey.isNotEmpty) { final localVersion = box.get("${cacheKey}_version}", defaultValue: ""); @@ -277,7 +250,8 @@ class WebViewModel { final data = json.decode(r); if (cacheKey.isNotEmpty) { dPrint( - "update $cacheKey v == $version time == ${(endTime.microsecondsSinceEpoch - startTime.microsecondsSinceEpoch) / 1000 / 1000}s"); + "update $cacheKey v == $version time == ${(endTime.microsecondsSinceEpoch - startTime.microsecondsSinceEpoch) / 1000 / 1000}s", + ); await box.put(cacheKey, data); await box.put("${cacheKey}_version}", version); } @@ -288,15 +262,26 @@ class WebViewModel { webview.addOnWebMessageReceivedCallback(callback); } - void removeOnWebMessageReceivedCallback( - OnWebMessageReceivedCallback callback) { + void removeOnWebMessageReceivedCallback(OnWebMessageReceivedCallback callback) { webview.removeOnWebMessageReceivedCallback(callback); } FutureOr dispose() { + webview.removeOnUrlRequestCallback(_onUrlRequest); if (loginMode && !_loginModeSuccess) { loginCallback?.call(null, false); } _isClosed = true; } + + void _onUrlRequest(String url) { + dPrint("OnUrlRequestCallback === $url"); + this.url = url; + + // 在页面开始加载时立即注入拦截器 + if (requestInterceptorScript.isNotEmpty) { + dPrint("Injecting request interceptor for: $url"); + webview.evaluateJavaScript(requestInterceptorScript); + } + } }