From 1e45744ee9ba6f702a05ced244fce808b3673a52 Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Wed, 22 Jul 2026 11:53:46 +0900 Subject: [PATCH] feat: complete anti-bot bypass with stealth mode and cookie proxy --- src-tauri/Cargo.lock | 19 ++++++++ src-tauri/Cargo.toml | 1 + src-tauri/src/commands.rs | 81 ++++++++++++++++++++++++++++++---- src-tauri/src/types.rs | 1 + src/components/MangaViewer.tsx | 15 +++++++ 5 files changed, 109 insertions(+), 8 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0005a6f..ecd4595 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2044,6 +2044,15 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -2202,6 +2211,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc2" version = "0.6.4" @@ -3687,6 +3705,7 @@ name = "tauri-app" version = "0.1.0" dependencies = [ "base64 0.22.1", + "objc", "reqwest 0.12.28", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3df5f39..4ce05fa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,3 +21,4 @@ reqwest = { version = "0.12", features = ["json", "cookies"] } tokio = { version = "1", features = ["full"] } base64 = "0.22" url = "2" +objc = "0.2.7" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 968d22a..7f471f8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -24,6 +24,7 @@ const SETTLE_GRACE_MS: u64 = 1200; /// Global state shared across commands (just the shared HTTP client for image proxying) struct AppState { client: reqwest::Client, + latest_cookies: Option, } static STATE: std::sync::OnceLock>> = std::sync::OnceLock::new(); @@ -50,7 +51,7 @@ fn get_state() -> Arc> { .build() .expect("Failed to build HTTP client"); - Arc::new(Mutex::new(AppState { client })) + Arc::new(Mutex::new(AppState { client, latest_cookies: None })) }).clone() } @@ -82,6 +83,7 @@ struct HarvestState { prev: Option, next: Option, href: Option, + cookies: Option, } /// Injected at document start: watches the DOM for the chapter's images to @@ -94,6 +96,28 @@ const INIT_SCRIPT: &str = r#" window.__manaInstalled = true; window.__mana = { ready: false }; + // === Anti-bot bypass: spoof viewport/screen dimensions === + // The site's ad-guard WASM reads screen/window dimensions for fingerprinting. + // Override them so a small window appears as a full-size browser. + try { + var FAKE_W = 1920, FAKE_H = 1080; + Object.defineProperty(window, 'innerWidth', { get: function(){ return FAKE_W; }, configurable: true }); + Object.defineProperty(window, 'innerHeight', { get: function(){ return FAKE_H; }, configurable: true }); + Object.defineProperty(window, 'outerWidth', { get: function(){ return FAKE_W; }, configurable: true }); + Object.defineProperty(window, 'outerHeight', { get: function(){ return FAKE_H; }, configurable: true }); + Object.defineProperty(document.documentElement, 'clientWidth', { get: function(){ return FAKE_W; }, configurable: true }); + Object.defineProperty(document.documentElement, 'clientHeight', { get: function(){ return FAKE_H; }, configurable: true }); + if (window.screen) { + Object.defineProperty(screen, 'width', { get: function(){ return FAKE_W; }, configurable: true }); + Object.defineProperty(screen, 'height', { get: function(){ return FAKE_H; }, configurable: true }); + Object.defineProperty(screen, 'availWidth', { get: function(){ return FAKE_W; }, configurable: true }); + Object.defineProperty(screen, 'availHeight',{ get: function(){ return FAKE_H; }, configurable: true }); + } + // Spoof Page Visibility API — always report visible + Object.defineProperty(document, 'hidden', { get: function(){ return false; }, configurable: true }); + Object.defineProperty(document, 'visibilityState', { get: function(){ return 'visible'; }, configurable: true }); + } catch(e) {} + var AD_HINTS = /ad|banner|gnb|lnb|footer|header|sns|menu|logo|popup|share|comment/i; // Filename/path hints for non-page images (ads, banners, UI chrome, // lazy-load placeholders) — checked against the resolved image URL itself, @@ -283,7 +307,7 @@ const INIT_SCRIPT: &str = r#" /// Reads back the collector's current snapshot. Always resolves to an object /// (never throws) so `eval_with_callback` has something to serialize. -const COLLECTOR_JS: &str = r#"(function(){ try { return window.__mana || {ready:false}; } catch(e) { return {ready:false}; } })()"#; +const COLLECTOR_JS: &str = r#"(function(){ try { var m = window.__mana || {ready:false}; m.cookies = document.cookie; return m; } catch(e) { return {ready:false}; } })()"#; async fn get_or_create_harvester( app: &AppHandle, @@ -302,14 +326,26 @@ async fn get_or_create_harvester( let win = WebviewWindowBuilder::new(app, HARVESTER_LABEL, WebviewUrl::External(url.clone())) .title("Mana Viewer — site session") - .inner_size(420.0, 620.0) - .position(24.0, 24.0) + .inner_size(200.0, 200.0) + .position(1600.0, 900.0) .decorations(false) + .focused(false) .visible(true) .initialization_script(INIT_SCRIPT) .build() .map_err(|e| e.to_string())?; + #[cfg(target_os = "macos")] + { + use objc::{msg_send, sel, sel_impl}; + if let Ok(ns_window) = win.ns_window() { + let ns_window = ns_window as *mut objc::runtime::Object; + unsafe { + let _: () = msg_send![ns_window, setAlphaValue: 0.0f64]; + } + } + } + // Debug builds only: open the Web Inspector for the harvester so its // console/DOM can be inspected directly — useful while tuning the // collector heuristics. @@ -401,6 +437,7 @@ async fn poll_harvest(app: AppHandle, req_id: u64, url: String) { next_chapter: state.next.clone(), title: state.title.clone(), series_url: state.href.as_deref().and_then(extract_series_url), + cookies: state.cookies.clone(), }; let _ = app.emit( "chapter-update", @@ -419,19 +456,42 @@ async fn poll_harvest(app: AppHandle, req_id: u64, url: String) { next_chapter: state.next, title: state.title, series_url: state.href.as_deref().and_then(extract_series_url), + cookies: state.cookies, }; eprintln!("[harvest#{}] ready with {} images", req_id, chapter.images.len()); + + // Save harvested cookies so fetch_image can use them + if let Some(cookies) = &chapter.cookies { + let state = get_state(); + let mut state_lock = state.lock().await; + state_lock.latest_cookies = Some(cookies.clone()); + } + let _ = app.emit( "chapter-ready", serde_json::json!({ "reqId": req_id, "data": chapter, "href": state.href }), ); - let _ = win.hide(); + // Don't hide — the site detects hide/show cycles and blocks + // image loading. The window stays small (200x200) in the corner. return; } } if !attention_sent && start.elapsed() >= Duration::from_secs(STALL_BEFORE_ATTENTION_SECS) { eprintln!("[harvest#{}] stalled, showing harvester window", req_id); + // Resize to usable dimensions so the user can solve the captcha + let _ = win.set_size(tauri::Size::Logical(tauri::LogicalSize { width: 420.0, height: 620.0 })); + let _ = win.set_position(tauri::Position::Logical(tauri::LogicalPosition { x: 24.0, y: 24.0 })); + #[cfg(target_os = "macos")] + { + use objc::{msg_send, sel, sel_impl}; + if let Ok(ns_window) = win.ns_window() { + let ns_window = ns_window as *mut objc::runtime::Object; + unsafe { + let _: () = msg_send![ns_window, setAlphaValue: 1.0f64]; + } + } + } let _ = win.show(); let _ = win.set_focus(); let _ = app.emit( @@ -464,11 +524,16 @@ pub async fn fetch_image(url: String, referer: String) -> Result let state = get_state(); let state_lock = state.lock().await; - let response = state_lock.client + let mut req = state_lock.client .get(&url) .header(REFERER, &referer) - .header("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8") - .send() + .header("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"); + + if let Some(cookies) = &state_lock.latest_cookies { + req = req.header("Cookie", cookies); + } + + let response = req.send() .await .map_err(|e| format!("Image fetch error: {}", e))?; diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index a91c2e8..8d855a3 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -7,6 +7,7 @@ pub struct ChapterData { pub next_chapter: Option, pub title: Option, pub series_url: Option, + pub cookies: Option, } #[derive(Debug, Serialize, Deserialize)] diff --git a/src/components/MangaViewer.tsx b/src/components/MangaViewer.tsx index b5928a6..2d1d374 100644 --- a/src/components/MangaViewer.tsx +++ b/src/components/MangaViewer.tsx @@ -102,6 +102,21 @@ export function MangaViewer({ setBoundaryWarning(null); }, [currentUrl]); + // Preload all images in the background so page turns are instant + useEffect(() => { + if (!chapter.images.length) return; + let cancelled = false; + const ref = new URL(currentUrl).origin; + // Fire off all fetches concurrently; fetchImage caches results + // so subsequent MangaPage renders will get instant cache hits. + chapter.images.forEach((imgUrl) => { + if (!cancelled) { + fetchImage(imgUrl, ref).catch(() => {}); // swallow errors – page will retry on its own + } + }); + return () => { cancelled = true; }; + }, [chapter.images, currentUrl, fetchImage]); + const goNext = useCallback(() => { if (slotIndex < totalSlots - 1) { setSlotIndex((i) => i + 1);