diff --git a/src-tauri/profiles/_default.json b/src-tauri/profiles/_default.json new file mode 100644 index 0000000..dd772af --- /dev/null +++ b/src-tauri/profiles/_default.json @@ -0,0 +1,40 @@ +{ + "id": "_default", + "name": "기본 (범용)", + "version": 1, + "domains": ["*"], + "selectors": { + "images": { + "containerSelector": null, + "imgSelector": "img", + "srcAttributes": ["data-src", "data-original", "data-lazy-src", "data-lazy", "currentSrc", "src"], + "pageOrderAttribute": null, + "minWidth": 150, + "excludeLandscape": true + }, + "navigation": { + "prevSelectors": [".theme-viewer-prev", ".btn-prev", ".prev", "a.prev"], + "nextSelectors": [".theme-viewer-next", ".btn-next", ".next", "a.next"], + "prevText": "이전화", + "nextText": "다음화" + }, + "title": { + "selectors": [".toon-title", "h1.title", "h1"], + "fallbackToDocumentTitle": true + } + }, + "filters": { + "adClassPattern": "ad|banner|gnb|lnb|footer|header|sns|menu|logo|popup|share|comment", + "adFilenamePattern": "(^|[\\/_.\\-])(ad|ads|banner|event|popup|logo|gnb|lnb|loading|spinner|placeholder|blank|noimage|no_image|notice|thumb|sns|share|button|btn|icon|dummy)([\\/_.\\-]|$)" + }, + "antiBot": { + "spoofViewport": true, + "spoofVisibility": true, + "fakeWidth": 1920, + "fakeHeight": 1080 + }, + "http": { + "needsCookies": true, + "refererPolicy": "origin" + } +} diff --git a/src-tauri/profiles/newtoki.json b/src-tauri/profiles/newtoki.json new file mode 100644 index 0000000..4d22258 --- /dev/null +++ b/src-tauri/profiles/newtoki.json @@ -0,0 +1,40 @@ +{ + "id": "newtoki", + "name": "뉴토끼", + "version": 1, + "domains": ["newtoki*.org", "newtoki*.com", "manatoki*.net", "manatoki*.org"], + "selectors": { + "images": { + "containerSelector": null, + "imgSelector": "img", + "srcAttributes": ["data-theme-page", "data-src", "data-original", "data-lazy-src", "data-lazy", "currentSrc", "src"], + "pageOrderAttribute": "data-theme-page", + "minWidth": 150, + "excludeLandscape": true + }, + "navigation": { + "prevSelectors": [".theme-viewer-prev", ".btn-prev", ".prev"], + "nextSelectors": [".theme-viewer-next", ".btn-next", ".next"], + "prevText": "이전화", + "nextText": "다음화" + }, + "title": { + "selectors": [".toon-title", "h1.title"], + "fallbackToDocumentTitle": true + } + }, + "filters": { + "adClassPattern": "ad|banner|gnb|lnb|footer|header|sns|menu|logo|popup|share|comment", + "adFilenamePattern": "(^|[\\/_.\\-])(ad|ads|banner|event|popup|logo|gnb|lnb|loading|spinner|placeholder|blank|noimage|no_image|notice|thumb|sns|share|button|btn|icon|dummy)([\\/_.\\-]|$)" + }, + "antiBot": { + "spoofViewport": true, + "spoofVisibility": true, + "fakeWidth": 1920, + "fakeHeight": 1080 + }, + "http": { + "needsCookies": true, + "refererPolicy": "origin" + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 961598c..f8a8af3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -10,6 +10,7 @@ use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; use tokio::sync::Mutex; use url::Url; +use crate::site_profile::{generate_init_script, ProfileManager, SiteProfile}; use crate::types::ChapterData; const HARVESTER_LABEL: &str = "harvester"; @@ -22,10 +23,11 @@ const HARVEST_TIMEOUT_SECS: u64 = 90; /// until the new document actually replaces it. const SETTLE_GRACE_MS: u64 = 1200; -/// Global state shared across commands (just the shared HTTP client for image proxying) +/// Global state shared across commands struct AppState { client: reqwest::Client, latest_cookies: Option, + profile_manager: ProfileManager, } static STATE: std::sync::OnceLock>> = std::sync::OnceLock::new(); @@ -52,7 +54,11 @@ fn get_state() -> Arc> { .build() .expect("Failed to build HTTP client"); - Arc::new(Mutex::new(AppState { client, latest_cookies: None })) + Arc::new(Mutex::new(AppState { + client, + latest_cookies: None, + profile_manager: ProfileManager::new(None), + })) }).clone() } @@ -87,234 +93,22 @@ struct HarvestState { cookies: Option, } -/// Injected at document start: watches the DOM for the chapter's images to -/// render and maintains `window.__mana` with the current best-effort snapshot. -/// Site-specific selectors here are heuristics and may need tuning if the -/// target site's markup changes. -const INIT_SCRIPT: &str = r#" -(function() { - if (window.__manaInstalled) return; - 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, - // since these often live outside the "ad" DOM containers the class-based - // filter above targets. - var FILENAME_AD_HINTS = /(^|[\/_.-])(ad|ads|banner|event|popup|logo|gnb|lnb|loading|spinner|placeholder|blank|noimage|no_image|notice|thumb|sns|share|button|btn|icon|dummy)([\/_.-]|$)/i; - - function isLikelyPageImage(src) { - try { - var u = new URL(src, location.href); - if (FILENAME_AD_HINTS.test(u.pathname)) return false; - } catch (e) { - // not a parseable URL — let other checks decide - } - return true; - } - - function isValidLink(href) { - return !!href && href !== '#' && href.indexOf('javascript:') !== 0 && href.indexOf('mailto:') !== 0; - } - - function resolveUrl(href) { - try { return new URL(href, location.href).href; } catch (e) { return href; } - } - - function findNav() { - var prev = null, next = null; - var pairs = [['.theme-viewer-prev', '.theme-viewer-next'], ['.btn-prev', '.btn-next'], ['.prev', '.next']]; - for (var i = 0; i < pairs.length; i++) { - var p = document.querySelector(pairs[i][0]); - var n = document.querySelector(pairs[i][1]); - var pHref = p && p.getAttribute('href'); - var nHref = n && n.getAttribute('href'); - if (pHref && isValidLink(pHref)) prev = resolveUrl(pHref); - if (nHref && isValidLink(nHref)) next = resolveUrl(nHref); - if (prev || next) break; - } - if (!prev || !next) { - var links = document.querySelectorAll('a'); - for (var j = 0; j < links.length; j++) { - var a = links[j]; - var href = a.getAttribute('href'); - if (!href || !isValidLink(href)) continue; - var text = (a.textContent || '').trim(); - if (!prev && text.indexOf('이전화') !== -1) prev = resolveUrl(href); - if (!next && text.indexOf('다음화') !== -1) next = resolveUrl(href); - } - } - return { prev: prev, next: next }; - } - - function findTitle() { - var sels = ['.toon-title', 'h1.title']; - for (var i = 0; i < sels.length; i++) { - var el = document.querySelector(sels[i]); - if (el && el.textContent && el.textContent.trim()) return el.textContent.trim(); - } - return document.title || null; - } - - window.__mana_found_pages = window.__mana_found_pages || {}; - window.__mana_found_list = window.__mana_found_list || []; - - function collectImages() { - var imgs = document.querySelectorAll('img'); - - // Check if the site uses data-theme-page - var hasThemePage = document.querySelector('img[data-theme-page]'); - - for (var i = 0; i < imgs.length; i++) { - var img = imgs[i]; - var src = img.getAttribute('data-src') - || img.getAttribute('data-original') - || img.getAttribute('data-lazy-src') - || img.getAttribute('data-lazy') - || img.currentSrc - || img.src - || ''; - - if (!src || src.indexOf('data:') === 0) continue; - - var pageNum = img.getAttribute('data-theme-page'); - if (pageNum) { - var p = parseInt(pageNum, 10); - if (!window.__mana_found_pages[p]) { - window.__mana_found_pages[p] = src; - } else if (window.__mana_found_pages[p].indexOf('blank') !== -1 && src.indexOf('blank') === -1) { - // Upgrade placeholder to real image if needed - window.__mana_found_pages[p] = src; - } - } else { - // Fallback heuristics for images without data-theme-page - if (!isLikelyPageImage(src)) continue; - var w = img.naturalWidth || parseInt(img.getAttribute('width') || '', 10) || 0; - var h = img.naturalHeight || parseInt(img.getAttribute('height') || '', 10) || 0; - if (w > 0 && h > 0 && w >= h) continue; - if (w > 0 && w < 150) continue; - - if (window.__mana_found_list.indexOf(src) === -1) { - window.__mana_found_list.push(src); - } - } - } - - // Combine both sources - var maxPage = 0; - for (var k in window.__mana_found_pages) { - if (parseInt(k, 10) > maxPage) maxPage = parseInt(k, 10); - } - var out = []; - for (var j = 1; j <= maxPage; j++) { - if (window.__mana_found_pages[j]) { - out.push(window.__mana_found_pages[j]); - } - } - for (var m = 0; m < window.__mana_found_list.length; m++) { - var s = window.__mana_found_list[m]; - if (out.indexOf(s) === -1) { - out.push(s); - } - } - return out; - } - - var lastCount = -1; - var stableTicks = 0; - var debounceTimer = null; - - var lastLoggedCount = -1; - var sampleLogsLeft = 4; - - function logSampleIfChanged(count) { - if (count === lastLoggedCount || sampleLogsLeft <= 0) return; - lastLoggedCount = count; - sampleLogsLeft--; - var imgs = document.querySelectorAll('img'); - console.log('[mana] total tags found:', imgs.length); - for (var k = 0; k < Math.min(8, imgs.length); k++) { - console.log('[mana] sample img[' + k + ']:', imgs[k].outerHTML.slice(0, 400)); - } - } - - function evaluate() { - logSampleIfChanged(document.querySelectorAll('img').length); - var images = collectImages(); - - if (images.length > 0 && images.length === lastCount) { - stableTicks++; - } else { - stableTicks = 0; - } - lastCount = images.length; - - var nav = findNav(); - var isReady = images.length > 0 && stableTicks >= 2; - - console.log('[mana] evaluate: images=' + images.length + ' stableTicks=' + stableTicks + ' href=' + location.href); - window.__mana = { - ready: isReady, - images: images, - title: findTitle(), - prev: nav.prev, - next: nav.next, - href: location.href - }; - } - - function scheduleEvaluate() { - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(evaluate, 200); - } - - var observer = new MutationObserver(scheduleEvaluate); - observer.observe(document.documentElement, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['src', 'data-src'] - }); - - // Periodic fallback: naturalWidth/lazy-load settling doesn't always fire a - // DOM mutation, so re-check on an interval too. - setInterval(evaluate, 200); - scheduleEvaluate(); -})(); -"#; - -/// Reads back the collector's current snapshot. Always resolves to an object -/// (never throws) so `eval_with_callback` has something to serialize. +/// JS executed during polling tick to collect current state and document cookies. 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, url: &Url, ) -> Result { + let state = get_state(); + let mut state_lock = state.lock().await; + // Reload custom profiles in case user saved/modified profiles + state_lock.profile_manager.load_custom_profiles(app); + let matched_profile = state_lock.profile_manager.find_profile_for_url(url.as_str()); + let init_script = generate_init_script(&matched_profile); + if let Some(win) = app.get_webview_window(HARVESTER_LABEL) { + let _ = win.eval(&init_script); win.navigate(url.clone()).map_err(|e| e.to_string())?; if let Ok(false) = win.is_visible() { let _ = win.show(); @@ -329,7 +123,7 @@ async fn get_or_create_harvester( .decorations(false) .focused(false) .visible(true) - .initialization_script(INIT_SCRIPT) + .initialization_script(&init_script) .build() .map_err(|e| e.to_string())?; @@ -569,3 +363,33 @@ pub async fn toggle_fullscreen(window: tauri::Window) -> Result { window.set_fullscreen(!is_full).map_err(|e| e.to_string())?; Ok(!is_full) } + +#[tauri::command] +pub async fn get_site_profiles(app: AppHandle) -> Result, String> { + let state = get_state(); + let mut state_lock = state.lock().await; + state_lock.profile_manager.load_custom_profiles(&app); + Ok(state_lock.profile_manager.list_profiles()) +} + +#[tauri::command] +pub async fn get_matched_profile(app: AppHandle, url: String) -> Result { + let state = get_state(); + let mut state_lock = state.lock().await; + state_lock.profile_manager.load_custom_profiles(&app); + Ok(state_lock.profile_manager.find_profile_for_url(&url)) +} + +#[tauri::command] +pub async fn save_site_profile(app: AppHandle, profile: SiteProfile) -> Result<(), String> { + let state = get_state(); + let mut state_lock = state.lock().await; + state_lock.profile_manager.save_custom_profile(&app, profile) +} + +#[tauri::command] +pub async fn delete_site_profile(app: AppHandle, id: String) -> Result<(), String> { + let state = get_state(); + let mut state_lock = state.lock().await; + state_lock.profile_manager.delete_custom_profile(&app, &id) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6f7bd7e..bb61caf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,7 @@ mod types; +mod site_profile; mod commands; - - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -11,6 +10,10 @@ pub fn run() { commands::open_chapter, commands::fetch_image, commands::toggle_fullscreen, + commands::get_site_profiles, + commands::get_matched_profile, + commands::save_site_profile, + commands::delete_site_profile, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/site_profile.rs b/src-tauri/src/site_profile.rs new file mode 100644 index 0000000..784ec4b --- /dev/null +++ b/src-tauri/src/site_profile.rs @@ -0,0 +1,490 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; +use url::Url; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ImageSelectorConfig { + pub container_selector: Option, + pub img_selector: String, + pub src_attributes: Vec, + pub page_order_attribute: Option, + pub min_width: u32, + pub exclude_landscape: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct NavigationSelectorConfig { + pub prev_selectors: Vec, + pub next_selectors: Vec, + pub prev_text: Option, + pub next_text: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TitleSelectorConfig { + pub selectors: Vec, + pub fallback_to_document_title: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SelectorsConfig { + pub images: ImageSelectorConfig, + pub navigation: NavigationSelectorConfig, + pub title: TitleSelectorConfig, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FiltersConfig { + pub ad_class_pattern: String, + pub ad_filename_pattern: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AntiBotConfig { + pub spoof_viewport: bool, + pub spoof_visibility: bool, + pub fake_width: u32, + pub fake_height: u32, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct HttpConfig { + pub needs_cookies: bool, + pub referer_policy: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SiteProfile { + pub id: String, + pub name: String, + pub version: u32, + pub domains: Vec, + pub selectors: SelectorsConfig, + pub filters: FiltersConfig, + pub anti_bot: AntiBotConfig, + pub http: HttpConfig, + #[serde(default)] + pub is_custom: bool, +} + +pub struct ProfileManager { + builtin_profiles: Vec, + custom_profiles: Vec, +} + +fn glob_match(pattern: &str, target: &str) -> bool { + if pattern == "*" { + return true; + } + let parts: Vec<&str> = pattern.split('*').collect(); + if parts.len() == 1 { + return pattern.eq_ignore_ascii_case(target); + } + + let mut remaining = target; + for (i, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + if i == 0 { + if !remaining.to_lowercase().starts_with(&part.to_lowercase()) { + return false; + } + remaining = &remaining[part.len()..]; + } else if i == parts.len() - 1 { + return remaining.to_lowercase().ends_with(&part.to_lowercase()); + } else { + if let Some(pos) = remaining.to_lowercase().find(&part.to_lowercase()) { + remaining = &remaining[pos + part.len()..]; + } else { + return false; + } + } + } + true +} + +impl ProfileManager { + pub fn new(app: Option<&AppHandle>) -> Self { + let default_json = include_str!("../profiles/_default.json"); + let newtoki_json = include_str!("../profiles/newtoki.json"); + + let default_prof: SiteProfile = serde_json::from_str(default_json).expect("Invalid _default.json"); + let newtoki_prof: SiteProfile = serde_json::from_str(newtoki_json).expect("Invalid newtoki.json"); + + let builtin_profiles = vec![newtoki_prof, default_prof]; + let mut manager = Self { + builtin_profiles, + custom_profiles: Vec::new(), + }; + + if let Some(app_handle) = app { + manager.load_custom_profiles(app_handle); + } + + manager + } + + fn get_user_profiles_dir(app: &AppHandle) -> Option { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let repo_dir = if cwd.join("src-tauri").exists() { + cwd.join("src-tauri").join("profiles") + } else if cwd.ends_with("src-tauri") { + cwd.join("profiles") + } else { + cwd.join("src-tauri").join("profiles") + }; + if repo_dir.exists() || fs::create_dir_all(&repo_dir).is_ok() { + Some(repo_dir) + } else { + app.path().app_data_dir().ok().map(|dir| dir.join("profiles")) + } + } + + pub fn load_custom_profiles(&mut self, app: &AppHandle) { + self.custom_profiles.clear(); + if let Some(dir) = Self::get_user_profiles_dir(app) { + if dir.exists() { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("json") { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(mut prof) = serde_json::from_str::(&content) { + prof.is_custom = true; + self.custom_profiles.push(prof); + } + } + } + } + } + } + } + } + + pub fn list_profiles(&self) -> Vec { + let mut list = self.custom_profiles.clone(); + for b in &self.builtin_profiles { + if !list.iter().any(|p| p.id == b.id) { + list.push(b.clone()); + } + } + list + } + + pub fn find_profile_for_url(&self, url_str: &str) -> SiteProfile { + let host = Url::parse(url_str) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_string())) + .unwrap_or_default(); + + // 1. Check custom profiles first + for prof in &self.custom_profiles { + for pattern in &prof.domains { + if glob_match(pattern, &host) { + return prof.clone(); + } + } + } + + // 2. Check built-in profiles (excluding default fallback) + for prof in &self.builtin_profiles { + if prof.id == "_default" { + continue; + } + for pattern in &prof.domains { + if glob_match(pattern, &host) { + return prof.clone(); + } + } + } + + // 3. Fallback to _default + self.builtin_profiles + .iter() + .find(|p| p.id == "_default") + .cloned() + .unwrap_or_else(|| self.builtin_profiles[0].clone()) + } + + pub fn save_custom_profile(&mut self, app: &AppHandle, mut profile: SiteProfile) -> Result<(), String> { + profile.is_custom = true; + let dir = Self::get_user_profiles_dir(app).ok_or("Failed to get app_data_dir")?; + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + + let file_path = dir.join(format!("{}.json", profile.id)); + let content = serde_json::to_string_pretty(&profile).map_err(|e| e.to_string())?; + fs::write(file_path, content).map_err(|e| e.to_string())?; + + self.load_custom_profiles(app); + Ok(()) + } + + pub fn delete_custom_profile(&mut self, app: &AppHandle, id: &str) -> Result<(), String> { + let dir = Self::get_user_profiles_dir(app).ok_or("Failed to get app_data_dir")?; + let file_path = dir.join(format!("{}.json", id)); + if file_path.exists() { + fs::remove_file(file_path).map_err(|e| e.to_string())?; + } + self.load_custom_profiles(app); + Ok(()) + } +} + +pub fn generate_init_script(profile: &SiteProfile) -> String { + let src_attrs_json = serde_json::to_string(&profile.selectors.images.src_attributes).unwrap_or_else(|_| "[]".to_string()); + let prev_selectors_json = serde_json::to_string(&profile.selectors.navigation.prev_selectors).unwrap_or_else(|_| "[]".to_string()); + let next_selectors_json = serde_json::to_string(&profile.selectors.navigation.next_selectors).unwrap_or_else(|_| "[]".to_string()); + let title_selectors_json = serde_json::to_string(&profile.selectors.title.selectors).unwrap_or_else(|_| "[]".to_string()); + + let page_order_attr_js = match &profile.selectors.images.page_order_attribute { + Some(attr) => format!("'{}'", attr), + None => "null".to_string(), + }; + + let prev_text_js = match &profile.selectors.navigation.prev_text { + Some(txt) => format!("'{}'", txt), + None => "null".to_string(), + }; + + let next_text_js = match &profile.selectors.navigation.next_text { + Some(txt) => format!("'{}'", txt), + None => "null".to_string(), + }; + + let spoof_viewport_js = if profile.anti_bot.spoof_viewport { + format!( + r#" + try {{ + var FAKE_W = {}, FAKE_H = {}; + 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 }}); + }} + }} catch(e) {{}} +"#, + profile.anti_bot.fake_width, profile.anti_bot.fake_height + ) + } else { + "".to_string() + }; + + let spoof_visibility_js = if profile.anti_bot.spoof_visibility { + r#" + try { + Object.defineProperty(document, 'hidden', { get: function(){ return false; }, configurable: true }); + Object.defineProperty(document, 'visibilityState', { get: function(){ return 'visible'; }, configurable: true }); + } catch(e) {} +"# + } else { + "" + }; + + format!( + r#"(function() {{ + if (window.__manaInstalled) return; + window.__manaInstalled = true; + window.__mana = {{ ready: false }}; + + // Anti-bot spoofing + {spoof_viewport_js} + {spoof_visibility_js} + + var FILENAME_AD_HINTS = new RegExp({ad_filename_pattern:?}, 'i'); + + function isLikelyPageImage(src) {{ + try {{ + var u = new URL(src, location.href); + if (FILENAME_AD_HINTS.test(u.pathname)) return false; + }} catch (e) {{}} + return true; + }} + + function isValidLink(href) {{ + return !!href && href !== '#' && href.indexOf('javascript:') !== 0 && href.indexOf('mailto:') !== 0; + }} + + function resolveUrl(href) {{ + try {{ return new URL(href, location.href).href; }} catch (e) {{ return href; }} + }} + + function findNav() {{ + var prev = null, next = null; + var prevSels = {prev_selectors_json}; + var nextSels = {next_selectors_json}; + var prevTxt = {prev_text_js}; + var nextTxt = {next_text_js}; + + for (var i = 0; i < Math.max(prevSels.length, nextSels.length); i++) {{ + var p = prevSels[i] ? document.querySelector(prevSels[i]) : null; + var n = nextSels[i] ? document.querySelector(nextSels[i]) : null; + var pHref = p && p.getAttribute('href'); + var nHref = n && n.getAttribute('href'); + if (!prev && pHref && isValidLink(pHref)) prev = resolveUrl(pHref); + if (!next && nHref && isValidLink(nHref)) next = resolveUrl(nHref); + if (prev && next) break; + }} + + if (!prev || !next) {{ + var links = document.querySelectorAll('a'); + for (var j = 0; j < links.length; j++) {{ + var a = links[j]; + var href = a.getAttribute('href'); + if (!href || !isValidLink(href)) continue; + var text = (a.textContent || '').trim(); + if (!prev && prevTxt && text.indexOf(prevTxt) !== -1) prev = resolveUrl(href); + if (!next && nextTxt && text.indexOf(nextTxt) !== -1) next = resolveUrl(href); + }} + }} + return {{ prev: prev, next: next }}; + }} + + function findTitle() {{ + var sels = {title_selectors_json}; + for (var i = 0; i < sels.length; i++) {{ + var el = document.querySelector(sels[i]); + if (el && el.textContent && el.textContent.trim()) return el.textContent.trim(); + }} + return {fallback_doc_title} ? (document.title || null) : null; + }} + + window.__mana_found_pages = window.__mana_found_pages || {{}}; + window.__mana_found_list = window.__mana_found_list || []; + + function collectImages() {{ + var imgs = document.querySelectorAll('{img_selector}'); + var srcAttrs = {src_attrs_json}; + var pageOrderAttr = {page_order_attr_js}; + var minW = {min_width}; + var excludeLandscape = {exclude_landscape}; + + for (var i = 0; i < imgs.length; i++) {{ + var img = imgs[i]; + var src = ''; + + for (var a = 0; a < srcAttrs.length; a++) {{ + var val = img.getAttribute(srcAttrs[a]); + if (val && val.indexOf('data:') !== 0) {{ + src = val; + break; + }} + }} + if (!src) src = img.currentSrc || img.src || ''; + if (!src || src.indexOf('data:') === 0) continue; + + var pageNum = pageOrderAttr ? img.getAttribute(pageOrderAttr) : null; + if (pageNum) {{ + var p = parseInt(pageNum, 10); + if (!window.__mana_found_pages[p]) {{ + window.__mana_found_pages[p] = src; + }} else if (window.__mana_found_pages[p].indexOf('blank') !== -1 && src.indexOf('blank') === -1) {{ + window.__mana_found_pages[p] = src; + }} + }} else {{ + if (!isLikelyPageImage(src)) continue; + var w = img.naturalWidth || parseInt(img.getAttribute('width') || '', 10) || 0; + var h = img.naturalHeight || parseInt(img.getAttribute('height') || '', 10) || 0; + if (excludeLandscape && w > 0 && h > 0 && w >= h) continue; + if (minW > 0 && w > 0 && w < minW) continue; + + if (window.__mana_found_list.indexOf(src) === -1) {{ + window.__mana_found_list.push(src); + }} + }} + }} + + var maxPage = 0; + for (var k in window.__mana_found_pages) {{ + if (parseInt(k, 10) > maxPage) maxPage = parseInt(k, 10); + }} + var out = []; + for (var j = 1; j <= maxPage; j++) {{ + if (window.__mana_found_pages[j]) {{ + out.push(window.__mana_found_pages[j]); + }} + }} + for (var m = 0; m < window.__mana_found_list.length; m++) {{ + var s = window.__mana_found_list[m]; + if (out.indexOf(s) === -1) {{ + out.push(s); + }} + }} + return out; + }} + + var lastCount = -1; + var stableTicks = 0; + var debounceTimer = null; + + function evaluate() {{ + var images = collectImages(); + + if (images.length > 0 && images.length === lastCount) {{ + stableTicks++; + }} else {{ + stableTicks = 0; + }} + lastCount = images.length; + + var nav = findNav(); + var isReady = images.length > 0 && stableTicks >= 2; + + window.__mana = {{ + ready: isReady, + images: images, + title: findTitle(), + prev: nav.prev, + next: nav.next, + href: location.href + }}; + }} + + function scheduleEvaluate() {{ + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(evaluate, 200); + }} + + var observer = new MutationObserver(scheduleEvaluate); + observer.observe(document.documentElement, {{ + childList: true, + subtree: true, + attributes: true + }}); + + setInterval(evaluate, 200); + scheduleEvaluate(); +}})();"#, + spoof_viewport_js = spoof_viewport_js, + spoof_visibility_js = spoof_visibility_js, + ad_filename_pattern = profile.filters.ad_filename_pattern, + prev_selectors_json = prev_selectors_json, + next_selectors_json = next_selectors_json, + prev_text_js = prev_text_js, + next_text_js = next_text_js, + title_selectors_json = title_selectors_json, + fallback_doc_title = profile.selectors.title.fallback_to_document_title, + img_selector = profile.selectors.images.img_selector, + src_attrs_json = src_attrs_json, + page_order_attr_js = page_order_attr_js, + min_width = profile.selectors.images.min_width, + exclude_landscape = profile.selectors.images.exclude_landscape + ) +} diff --git a/src/App.css b/src/App.css index 1fd3720..870005d 100644 --- a/src/App.css +++ b/src/App.css @@ -128,6 +128,27 @@ body { gap: 10px; } +.profile-trigger-row { + display: flex; + justify-content: flex-end; +} + +.btn-link-profile { + background: transparent; + border: none; + color: var(--color-text-subtle); + font-size: 12px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + transition: all var(--transition-fast); +} + +.btn-link-profile:hover { + color: var(--color-primary-light, #a78bfa); + background: rgba(124, 58, 237, 0.1); +} + .input-row { display: flex; gap: 8px; @@ -1263,3 +1284,279 @@ kbd { ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.25); } + +/* ============================================================ + PROFILE MANAGER MODAL + ============================================================ */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 999; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.profile-modal { + width: 100%; + max-width: 900px; + height: 80vh; + max-height: 700px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.profile-modal-header { + padding: 16px 20px; + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(255, 255, 255, 0.02); +} + +.header-title-group { + display: flex; + flex-direction: column; + gap: 2px; +} + +.header-title-group h3 { + font-size: 16px; + font-weight: 700; + color: var(--color-text); + margin: 0; +} + +.header-title-group .subtitle { + font-size: 12px; + color: var(--color-text-muted); +} + +.close-btn { + background: transparent; + border: none; + color: var(--color-text-subtle); + font-size: 18px; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + transition: all var(--transition-fast); +} + +.close-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--color-text); +} + +.profile-modal-body { + flex: 1; + display: flex; + overflow: hidden; +} + +.profile-sidebar { + width: 260px; + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; + background: rgba(0, 0, 0, 0.2); +} + +.sidebar-header { + padding: 12px 14px; + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + font-weight: 600; + color: var(--color-text-muted); +} + +.btn-new { + background: var(--color-primary); + color: white; + border: none; + padding: 4px 8px; + font-size: 11px; + font-weight: 600; + border-radius: 4px; + cursor: pointer; +} + +.btn-new:hover { + opacity: 0.9; +} + +.profile-list { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.profile-list-item { + padding: 10px 12px; + border-radius: var(--radius-md); + border: 1px solid transparent; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 4px; + transition: all var(--transition-fast); +} + +.profile-list-item:hover { + background: var(--color-surface-hover); +} + +.profile-list-item.selected { + background: rgba(124, 58, 237, 0.15); + border-color: rgba(124, 58, 237, 0.4); +} + +.item-name-row { + display: flex; + align-items: center; + gap: 6px; +} + +.profile-name { + font-size: 13px; + font-weight: 600; + color: var(--color-text); + +} + +.custom-badge { + font-size: 10px; + background: rgba(16, 185, 129, 0.2); + color: #10b981; + padding: 1px 5px; + border-radius: 4px; +} + +.default-badge { + font-size: 10px; + background: rgba(255, 255, 255, 0.1); + color: var(--color-text-muted); + padding: 1px 5px; + border-radius: 4px; +} + +.profile-domains { + font-size: 11px; + color: var(--color-text-subtle); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-editor-container { + flex: 1; + display: flex; + flex-direction: column; + padding: 14px; + gap: 10px; + background: var(--color-bg); +} + +.editor-toolbar { + display: flex; + align-items: center; + justify-content: space-between; +} + +.editor-title { + font-size: 13px; + font-weight: 600; + color: var(--color-text); +} + +.editor-actions { + display: flex; + gap: 8px; +} + +.btn-secondary { + background: var(--color-surface); + border: 1px solid var(--color-border); + color: var(--color-text); + padding: 6px 12px; + font-size: 12px; + border-radius: 6px; + cursor: pointer; +} + +.btn-primary { + background: linear-gradient(135deg, #7c3aed, #4f46e5); + color: white; + border: none; + padding: 6px 14px; + font-size: 12px; + font-weight: 600; + border-radius: 6px; + cursor: pointer; +} + +.btn-danger { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.3); + padding: 6px 12px; + font-size: 12px; + border-radius: 6px; + cursor: pointer; +} + +.btn-danger:hover { + background: rgba(239, 68, 68, 0.3); +} + +.json-error-banner { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #f87171; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; +} + +.json-success-banner { + background: rgba(16, 185, 129, 0.15); + border: 1px solid rgba(16, 185, 129, 0.3); + color: #34d399; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; +} + +.json-textarea { + flex: 1; + width: 100%; + background: #0d0d12; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + color: #a7f3d0; + font-family: 'Fira Code', 'Cascadia Code', Consolas, monospace; + font-size: 12px; + line-height: 1.5; + padding: 12px; + resize: none; + outline: none; +} + +.json-textarea:focus { + border-color: var(--color-primary); +} diff --git a/src/components/ProfileManagerModal.tsx b/src/components/ProfileManagerModal.tsx new file mode 100644 index 0000000..df458cc --- /dev/null +++ b/src/components/ProfileManagerModal.tsx @@ -0,0 +1,223 @@ +import { useState, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import type { SiteProfile } from '../types'; + +interface ProfileManagerModalProps { + isOpen: boolean; + onClose: () => void; + currentUrl?: string; +} + +const TEMPLATE_PROFILE: SiteProfile = { + id: 'custom_manga_site', + name: '새 만화 사이트', + version: 1, + domains: ['example.com', '*.example.com'], + selectors: { + images: { + containerSelector: null, + imgSelector: 'img', + srcAttributes: ['data-src', 'data-original', 'data-lazy-src', 'src'], + pageOrderAttribute: null, + minWidth: 150, + excludeLandscape: true, + }, + navigation: { + prevSelectors: ['.btn-prev', '.prev'], + nextSelectors: ['.btn-next', '.next'], + prevText: '이전화', + nextText: '다음화', + }, + title: { + selectors: ['.title', 'h1'], + fallbackToDocumentTitle: true, + }, + }, + filters: { + adClassPattern: 'ad|banner|footer|header|popup', + adFilenamePattern: '(^|[\\/_.\\-])(ad|ads|banner|event|popup|logo|icon)([\\/_.\\-]|$)', + }, + antiBot: { + spoofViewport: true, + spoofVisibility: true, + fakeWidth: 1920, + fakeHeight: 1080, + }, + http: { + needsCookies: true, + refererPolicy: 'origin', + }, + isCustom: true, +}; + +export function ProfileManagerModal({ isOpen, onClose, currentUrl }: ProfileManagerModalProps) { + const [profiles, setProfiles] = useState([]); + const [selectedId, setSelectedId] = useState('_default'); + const [jsonText, setJsonText] = useState(''); + const [jsonError, setJsonError] = useState(null); + const [saveStatus, setSaveStatus] = useState(null); + + const loadProfiles = async () => { + try { + const list = await invoke('get_site_profiles'); + setProfiles(list); + + if (currentUrl) { + try { + const matched = await invoke('get_matched_profile', { url: currentUrl }); + setSelectedId(matched.id); + setJsonText(JSON.stringify(matched, null, 2)); + return; + } catch {} + } + + if (list.length > 0) { + const initial = list.find((p) => p.id === selectedId) || list[0]; + setSelectedId(initial.id); + setJsonText(JSON.stringify(initial, null, 2)); + } + } catch (err) { + setJsonError(`프로필 목록 로드 실패: ${String(err)}`); + } + }; + + useEffect(() => { + if (isOpen) { + loadProfiles(); + setSaveStatus(null); + setJsonError(null); + } + }, [isOpen, currentUrl]); + + if (!isOpen) return null; + + const handleSelectProfile = (p: SiteProfile) => { + setSelectedId(p.id); + setJsonText(JSON.stringify(p, null, 2)); + setJsonError(null); + setSaveStatus(null); + }; + + const handleCreateNew = () => { + const newId = `site_${Date.now()}`; + const newProf: SiteProfile = { + ...TEMPLATE_PROFILE, + id: newId, + name: '새 사이트 프로필', + }; + setSelectedId(newId); + setJsonText(JSON.stringify(newProf, null, 2)); + setJsonError(null); + setSaveStatus('새 프로필 템플릿이 생성되었습니다. 수정 후 [저장]을 눌러주세요.'); + }; + + const handleFormatJson = () => { + try { + const parsed = JSON.parse(jsonText); + setJsonText(JSON.stringify(parsed, null, 2)); + setJsonError(null); + } catch (e: any) { + setJsonError(`JSON 문법 오류: ${e.message}`); + } + }; + + const handleSave = async () => { + try { + const parsed: SiteProfile = JSON.parse(jsonText); + if (!parsed.id || !parsed.name || !Array.isArray(parsed.domains)) { + setJsonError('필수 항목이 누락되었습니다 (id, name, domains 필드 필요).'); + return; + } + await invoke('save_site_profile', { profile: parsed }); + setSaveStatus('✅ 프로필이 성공적으로 저장되었습니다!'); + setJsonError(null); + await loadProfiles(); + setSelectedId(parsed.id); + } catch (e: any) { + setJsonError(`저장 실패: ${e.message || String(e)}`); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm(`'${id}' 프로필을 삭제하시겠습니까?`)) return; + try { + await invoke('delete_site_profile', { id }); + setSaveStatus('프로필이 삭제되었습니다.'); + await loadProfiles(); + } catch (e: any) { + setJsonError(`삭제 실패: ${e.message || String(e)}`); + } + }; + + return ( +
+
e.stopPropagation()}> +
+
+

🌐 사이트 템플릿 프로필 관리

+ 도메인별 이미지 및 이동 셀렉터 파싱 설정 +
+ +
+ +
+ {/* Left Sidebar: Profile List */} +
+
+ 등록된 사이트 ({profiles.length}) + +
+
+ {profiles.map((p) => { + const isSelected = p.id === selectedId; + return ( +
handleSelectProfile(p)} + > +
+ {p.name} + {p.isCustom && 사용자} + {p.id === '_default' && 기본} +
+ {p.domains.join(', ')} +
+ ); + })} +
+
+ + {/* Right Area: JSON Editor */} +
+
+ JSON 템플릿 편집 ({selectedId}) +
+ + + {selectedId !== '_default' && selectedId !== 'newtoki' && ( + + )} +
+
+ + {jsonError &&
⚠️ {jsonError}
} + {saveStatus &&
{saveStatus}
} + +