feat: complete anti-bot bypass with stealth mode and cookie proxy

main
I Luk Kim 3 weeks ago
parent 70aa25de01
commit 1e45744ee9

19
src-tauri/Cargo.lock generated

@ -2044,6 +2044,15 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" 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]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.38.0" version = "0.38.0"
@ -2202,6 +2211,15 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "objc2" name = "objc2"
version = "0.6.4" version = "0.6.4"
@ -3687,6 +3705,7 @@ name = "tauri-app"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"objc",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",

@ -21,3 +21,4 @@ reqwest = { version = "0.12", features = ["json", "cookies"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
base64 = "0.22" base64 = "0.22"
url = "2" url = "2"
objc = "0.2.7"

@ -24,6 +24,7 @@ const SETTLE_GRACE_MS: u64 = 1200;
/// Global state shared across commands (just the shared HTTP client for image proxying) /// Global state shared across commands (just the shared HTTP client for image proxying)
struct AppState { struct AppState {
client: reqwest::Client, client: reqwest::Client,
latest_cookies: Option<String>,
} }
static STATE: std::sync::OnceLock<Arc<Mutex<AppState>>> = std::sync::OnceLock::new(); static STATE: std::sync::OnceLock<Arc<Mutex<AppState>>> = std::sync::OnceLock::new();
@ -50,7 +51,7 @@ fn get_state() -> Arc<Mutex<AppState>> {
.build() .build()
.expect("Failed to build HTTP client"); .expect("Failed to build HTTP client");
Arc::new(Mutex::new(AppState { client })) Arc::new(Mutex::new(AppState { client, latest_cookies: None }))
}).clone() }).clone()
} }
@ -82,6 +83,7 @@ struct HarvestState {
prev: Option<String>, prev: Option<String>,
next: Option<String>, next: Option<String>,
href: Option<String>, href: Option<String>,
cookies: Option<String>,
} }
/// Injected at document start: watches the DOM for the chapter's images to /// 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.__manaInstalled = true;
window.__mana = { ready: false }; 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; 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, // Filename/path hints for non-page images (ads, banners, UI chrome,
// lazy-load placeholders) — checked against the resolved image URL itself, // 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 /// Reads back the collector's current snapshot. Always resolves to an object
/// (never throws) so `eval_with_callback` has something to serialize. /// (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( async fn get_or_create_harvester(
app: &AppHandle, app: &AppHandle,
@ -302,14 +326,26 @@ async fn get_or_create_harvester(
let win = WebviewWindowBuilder::new(app, HARVESTER_LABEL, WebviewUrl::External(url.clone())) let win = WebviewWindowBuilder::new(app, HARVESTER_LABEL, WebviewUrl::External(url.clone()))
.title("Mana Viewer — site session") .title("Mana Viewer — site session")
.inner_size(420.0, 620.0) .inner_size(200.0, 200.0)
.position(24.0, 24.0) .position(1600.0, 900.0)
.decorations(false) .decorations(false)
.focused(false)
.visible(true) .visible(true)
.initialization_script(INIT_SCRIPT) .initialization_script(INIT_SCRIPT)
.build() .build()
.map_err(|e| e.to_string())?; .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 // Debug builds only: open the Web Inspector for the harvester so its
// console/DOM can be inspected directly — useful while tuning the // console/DOM can be inspected directly — useful while tuning the
// collector heuristics. // collector heuristics.
@ -401,6 +437,7 @@ async fn poll_harvest(app: AppHandle, req_id: u64, url: String) {
next_chapter: state.next.clone(), next_chapter: state.next.clone(),
title: state.title.clone(), title: state.title.clone(),
series_url: state.href.as_deref().and_then(extract_series_url), series_url: state.href.as_deref().and_then(extract_series_url),
cookies: state.cookies.clone(),
}; };
let _ = app.emit( let _ = app.emit(
"chapter-update", "chapter-update",
@ -419,19 +456,42 @@ async fn poll_harvest(app: AppHandle, req_id: u64, url: String) {
next_chapter: state.next, next_chapter: state.next,
title: state.title, title: state.title,
series_url: state.href.as_deref().and_then(extract_series_url), series_url: state.href.as_deref().and_then(extract_series_url),
cookies: state.cookies,
}; };
eprintln!("[harvest#{}] ready with {} images", req_id, chapter.images.len()); 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( let _ = app.emit(
"chapter-ready", "chapter-ready",
serde_json::json!({ "reqId": req_id, "data": chapter, "href": state.href }), 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; return;
} }
} }
if !attention_sent && start.elapsed() >= Duration::from_secs(STALL_BEFORE_ATTENTION_SECS) { if !attention_sent && start.elapsed() >= Duration::from_secs(STALL_BEFORE_ATTENTION_SECS) {
eprintln!("[harvest#{}] stalled, showing harvester window", req_id); 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.show();
let _ = win.set_focus(); let _ = win.set_focus();
let _ = app.emit( let _ = app.emit(
@ -464,11 +524,16 @@ pub async fn fetch_image(url: String, referer: String) -> Result<String, String>
let state = get_state(); let state = get_state();
let state_lock = state.lock().await; let state_lock = state.lock().await;
let response = state_lock.client let mut req = state_lock.client
.get(&url) .get(&url)
.header(REFERER, &referer) .header(REFERER, &referer)
.header("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8") .header("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8");
.send()
if let Some(cookies) = &state_lock.latest_cookies {
req = req.header("Cookie", cookies);
}
let response = req.send()
.await .await
.map_err(|e| format!("Image fetch error: {}", e))?; .map_err(|e| format!("Image fetch error: {}", e))?;

@ -7,6 +7,7 @@ pub struct ChapterData {
pub next_chapter: Option<String>, pub next_chapter: Option<String>,
pub title: Option<String>, pub title: Option<String>,
pub series_url: Option<String>, pub series_url: Option<String>,
pub cookies: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]

@ -102,6 +102,21 @@ export function MangaViewer({
setBoundaryWarning(null); setBoundaryWarning(null);
}, [currentUrl]); }, [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(() => { const goNext = useCallback(() => {
if (slotIndex < totalSlots - 1) { if (slotIndex < totalSlots - 1) {
setSlotIndex((i) => i + 1); setSlotIndex((i) => i + 1);

Loading…
Cancel
Save