feat: implement site profile template parsing system with JSON editor UI

main
I Luk Kim 3 weeks ago
parent 0f98717c8f
commit 3b8e50ac6f

@ -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"
}
}

@ -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"
}
}

@ -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<String>,
profile_manager: ProfileManager,
}
static STATE: std::sync::OnceLock<Arc<Mutex<AppState>>> = std::sync::OnceLock::new();
@ -52,7 +54,11 @@ fn get_state() -> Arc<Mutex<AppState>> {
.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<String>,
}
/// 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 <img> 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<tauri::WebviewWindow, String> {
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<bool, String> {
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<Vec<SiteProfile>, 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<SiteProfile, 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.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)
}

@ -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");

@ -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<String>,
pub img_selector: String,
pub src_attributes: Vec<String>,
pub page_order_attribute: Option<String>,
pub min_width: u32,
pub exclude_landscape: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct NavigationSelectorConfig {
pub prev_selectors: Vec<String>,
pub next_selectors: Vec<String>,
pub prev_text: Option<String>,
pub next_text: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TitleSelectorConfig {
pub selectors: Vec<String>,
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<String>,
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<SiteProfile>,
custom_profiles: Vec<SiteProfile>,
}
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<PathBuf> {
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::<SiteProfile>(&content) {
prof.is_custom = true;
self.custom_profiles.push(prof);
}
}
}
}
}
}
}
}
pub fn list_profiles(&self) -> Vec<SiteProfile> {
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
)
}

@ -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);
}

@ -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<SiteProfile[]>([]);
const [selectedId, setSelectedId] = useState<string>('_default');
const [jsonText, setJsonText] = useState<string>('');
const [jsonError, setJsonError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<string | null>(null);
const loadProfiles = async () => {
try {
const list = await invoke<SiteProfile[]>('get_site_profiles');
setProfiles(list);
if (currentUrl) {
try {
const matched = await invoke<SiteProfile>('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 (
<div className="modal-overlay" onClick={onClose}>
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
<div className="profile-modal-header">
<div className="header-title-group">
<h3>🌐 릿 </h3>
<span className="subtitle"> </span>
</div>
<button className="close-btn" onClick={onClose} title="닫기"></button>
</div>
<div className="profile-modal-body">
{/* Left Sidebar: Profile List */}
<div className="profile-sidebar">
<div className="sidebar-header">
<span> ({profiles.length})</span>
<button className="btn-new" onClick={handleCreateNew}>+ </button>
</div>
<div className="profile-list">
{profiles.map((p) => {
const isSelected = p.id === selectedId;
return (
<div
key={p.id}
className={`profile-list-item ${isSelected ? 'selected' : ''}`}
onClick={() => handleSelectProfile(p)}
>
<div className="item-name-row">
<span className="profile-name">{p.name}</span>
{p.isCustom && <span className="custom-badge"></span>}
{p.id === '_default' && <span className="default-badge"></span>}
</div>
<span className="profile-domains">{p.domains.join(', ')}</span>
</div>
);
})}
</div>
</div>
{/* Right Area: JSON Editor */}
<div className="profile-editor-container">
<div className="editor-toolbar">
<span className="editor-title">JSON 릿 ({selectedId})</span>
<div className="editor-actions">
<button className="btn-secondary" onClick={handleFormatJson}> (Format)</button>
<button className="btn-primary" onClick={handleSave}>💾 </button>
{selectedId !== '_default' && selectedId !== 'newtoki' && (
<button className="btn-danger" onClick={() => handleDelete(selectedId)}></button>
)}
</div>
</div>
{jsonError && <div className="json-error-banner"> {jsonError}</div>}
{saveStatus && <div className="json-success-banner">{saveStatus}</div>}
<textarea
className="json-textarea"
value={jsonText}
onChange={(e) => {
setJsonText(e.target.value);
setJsonError(null);
setSaveStatus(null);
}}
spellCheck={false}
placeholder="SiteProfile JSON 입력..."
/>
</div>
</div>
</div>
</div>
);
}

@ -1,4 +1,6 @@
import { useState } from 'react';
import type { ViewerSettings, FitMode, BackgroundColor } from '../types';
import { ProfileManagerModal } from './ProfileManagerModal';
interface SettingsPanelProps {
settings: ViewerSettings;
@ -8,6 +10,7 @@ interface SettingsPanelProps {
}
export function SettingsPanel({ settings, totalImages, onChange, onClose }: SettingsPanelProps) {
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
return (
<div className="settings-overlay" onClick={onClose}>
<div className="settings-panel" onClick={(e) => e.stopPropagation()}>
@ -187,8 +190,24 @@ export function SettingsPanel({ settings, totalImages, onChange, onClose }: Sett
))}
</div>
</div>
{/* Site Profile Manager */}
<div className="settings-group">
<label className="settings-label"> 릿</label>
<button
className="btn-secondary"
style={{ width: '100%', padding: '10px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }}
onClick={() => setIsProfileModalOpen(true)}
>
🌐 릿 (JSON)
</button>
</div>
</div>
<ProfileManagerModal
isOpen={isProfileModalOpen}
onClose={() => setIsProfileModalOpen(false)}
/>
{/* Keyboard shortcuts */}
<div className="settings-footer">
<h3 className="shortcuts-title"> </h3>

@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import type { HistoryEntry } from '../types';
import { parseTitleAndEpisode } from '../utils/titleParser';
import { ProfileManagerModal } from './ProfileManagerModal';
interface UrlInputProps {
onLoad: (url: string) => void;
@ -33,6 +34,7 @@ export function UrlInput({
}: UrlInputProps) {
const [url, setUrl] = useState('');
const [history, setHistory] = useState<HistoryEntry[]>(getHistory);
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@ -52,6 +54,11 @@ export function UrlInput({
return (
<div className="url-input-screen">
<ProfileManagerModal
isOpen={isProfileModalOpen}
onClose={() => setIsProfileModalOpen(false)}
currentUrl={url}
/>
{isLoading && (
<div className="full-loading-overlay" onClick={(e) => e.stopPropagation()}>
<div className="loading-spinner-large" />
@ -115,6 +122,16 @@ export function UrlInput({
</button>
</div>
<div className="profile-trigger-row">
<button
type="button"
className="btn-link-profile"
onClick={() => setIsProfileModalOpen(true)}
>
🌐 릿 / JSON
</button>
</div>
{/* Needs-attention hint: the site's own challenge (Cloudflare/login/etc.)
is being shown in a real browser window for the user to solve. */}
{isLoading && needsAttention && (

@ -28,3 +28,59 @@ export interface HistoryEntry {
seriesUrl?: string | null;
timestamp: number;
}
export interface ImageSelectorConfig {
containerSelector: string | null;
imgSelector: string;
srcAttributes: string[];
pageOrderAttribute: string | null;
minWidth: number;
excludeLandscape: boolean;
}
export interface NavigationSelectorConfig {
prevSelectors: string[];
nextSelectors: string[];
prevText: string | null;
nextText: string | null;
}
export interface TitleSelectorConfig {
selectors: string[];
fallbackToDocumentTitle: boolean;
}
export interface SelectorsConfig {
images: ImageSelectorConfig;
navigation: NavigationSelectorConfig;
title: TitleSelectorConfig;
}
export interface FiltersConfig {
adClassPattern: string;
adFilenamePattern: string;
}
export interface AntiBotConfig {
spoofViewport: boolean;
spoofVisibility: boolean;
fakeWidth: number;
fakeHeight: number;
}
export interface HttpConfig {
needsCookies: boolean;
refererPolicy: string;
}
export interface SiteProfile {
id: string;
name: string;
version: number;
domains: string[];
selectors: SelectorsConfig;
filters: FiltersConfig;
antiBot: AntiBotConfig;
http: HttpConfig;
isCustom?: boolean;
}

Loading…
Cancel
Save