import Foundation import Combine import WebKit /// Scraper que utiliza WKWebView para extraer contenido de manhwaweb.com. /// /// `ManhwaWebScraper` implementa la extracción de datos de sitios web que usan /// JavaScript dinámico para renderizar contenido. Esta estrategia es necesaria /// porque manhwaweb.com carga su contenido mediante JavaScript después de la /// carga inicial de la página, lo que impide el uso de HTTP requests simples. /// /// El scraper utiliza un `WKWebView` invisible para cargar páginas, esperar a que /// JavaScript termine de ejecutarse, y luego extraer la información mediante /// inyección de JavaScript. /// /// # Example /// ```swift /// let scraper = ManhwaWebScraper.shared /// do { /// let manga = try await scraper.scrapeMangaInfo(mangaSlug: "one-piece_1695365223767") /// print("Manga: \(manga.title)") /// /// let chapters = try await scraper.scrapeChapters(mangaSlug: manga.slug) /// print("Capítulos: \(chapters.count)") /// } catch { /// print("Error: \(error.localizedDescription)") /// } /// ``` @MainActor class ManhwaWebScraper: NSObject, ObservableObject { // MARK: - Properties /// WebView instance para cargar y ejecutar JavaScript private var webView: WKWebView? /// Continuation usada para operaciones async de espera private var continuation: CheckedContinuation? // MARK: - Singleton /// Instancia compartida del scraper (Singleton pattern) static let shared = ManhwaWebScraper() // MARK: - Initialization /// Inicializador privado para implementar Singleton private override init() { super.init() setupWebView() } // MARK: - Setup /// Configura el WKWebView con preferencias optimizadas para scraping. /// /// Configura: /// - User Agent personalizado para simular un iPhone /// - JavaScript habilitado para ejecutar scripts en las páginas /// - Navigation delegate para monitorear carga de páginas private func setupWebView() { let configuration = WKWebViewConfiguration() configuration.applicationNameForUserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15" // Preferencias para mejor rendimiento let preferences = WKPreferences() preferences.javaScriptEnabled = true configuration.preferences = preferences webView = WKWebView(frame: .zero, configuration: configuration) webView?.navigationDelegate = self } // MARK: - Scraper Functions /// Obtiene la lista de capítulos de un manga desde manhwaweb.com. /// /// Este método carga la página del manga, espera a que JavaScript renderice /// el contenido, y extrae todos los links de capítulos disponibles. /// /// # Proceso /// 1. Carga la URL del manga en WKWebView /// 2. Espera 3 segundos a que JavaScript termine /// 3. Ejecuta JavaScript para extraer capítulos /// 4. Filtra duplicados y ordena descendentemente /// /// - Parameter mangaSlug: Slug único del manga (ej: `"one-piece_1695365223767"`) /// - Returns: Array de `Chapter` ordenados por número (descendente) /// - Throws: `ScrapingError` si el WebView no está inicializado o falla la extracción /// /// # Example /// ```swift /// do { /// let chapters = try await scraper.scrapeChapters(mangaSlug: "one-piece_1695365223767") /// print("Found \(chapters.count) chapters") /// for chapter in chapters.prefix(5) { /// print("- Chapter \(chapter.number): \(chapter.title)") /// } /// } catch { /// print("Failed to scrape chapters: \(error)") /// } /// ``` func scrapeChapters(mangaSlug: String) async throws -> [Chapter] { guard let webView = webView else { throw ScrapingError.webViewNotInitialized } let url = URL(string: "https://manhwaweb.com/manga/\(mangaSlug)")! var chapters: [Chapter] = [] // Load URL and wait try await loadURLAndWait(url) // Extract chapters using JavaScript chapters = try await webView.evaluateJavaScript(""" (function() { const chapters = []; const links = document.querySelectorAll('a[href*="/leer/"]'); links.forEach(link => { const href = link.getAttribute('href'); const text = link.textContent?.trim(); if (href && text && href.includes('/leer/')) { // Extraer número de capítulo const match = href.match(/(\\d+)(?:\\/|\\?|\\s*$)/); const chapterNumber = match ? parseInt(match[1]) : null; if (chapterNumber && !isNaN(chapterNumber)) { chapters.push({ number: chapterNumber, title: text, url: href.startsWith('http') ? href : 'https://manhwaweb.com' + href, slug: href.replace('/leer/', '').replace(/^\\//, '') }); } } }); // Eliminar duplicados const unique = chapters.filter((chapter, index, self) => index === self.findIndex((c) => c.number === chapter.number) ); // Ordenar descendente return unique.sort((a, b) => b.number - a.number); })(); """) as! [ [String: Any] ] let parsedChapters = chapters.compactMap { dict -> Chapter? in guard let number = dict["number"] as? Int, let title = dict["title"] as? String, let url = dict["url"] as? String, let slug = dict["slug"] as? String else { return nil } return Chapter(number: number, title: title, url: url, slug: slug) } return parsedChapters } /// Obtiene las URLs de las imágenes de un capítulo. /// /// Este método carga la página de lectura de un capítulo, espera a que /// las imágenes carguen, y extrae todas las URLs de imágenes del contenido. /// /// # Proceso /// 1. Carga la URL del capítulo en WKWebView /// 2. Espera 5 segundos (más tiempo para cargar imágenes) /// 3. Ejecuta JavaScript para extraer URLs de `` tags /// 4. Filtra elementos de UI (avatars, icons, logos) /// 5. Elimina duplicados preservando orden /// /// - Parameter chapterSlug: Slug del capítulo (ej: `"one-piece/capitulo-1"`) /// - Returns: Array de strings con URLs de imágenes en orden /// - Throws: `ScrapingError` si el WebView no está inicializado o falla la carga /// /// # Example /// ```swift /// do { /// let images = try await scraper.scrapeChapterImages(chapterSlug: "one-piece/1") /// print("Found \(images.count) pages") /// for (index, imageUrl) in images.enumerated() { /// print("Page \(index + 1): \(imageUrl)") /// } /// } catch { /// print("Failed to scrape images: \(error)") /// } /// ``` func scrapeChapterImages(chapterSlug: String) async throws -> [String] { guard let webView = webView else { throw ScrapingError.webViewNotInitialized } let url = URL(string: "https://manhwaweb.com/leer/\(chapterSlug)")! var images: [String] = [] // Load URL and wait try await loadURLAndWait(url, waitForImages: true) // Extract image URLs using JavaScript images = try await webView.evaluateJavaScript(""" (function() { const imageUrls = []; const imgs = document.querySelectorAll('img'); imgs.forEach(img => { let src = img.src || img.getAttribute('data-src'); if (src) { // Filtrar UI elements const alt = (img.alt || '').toLowerCase(); const className = (img.className || '').toLowerCase(); const isUIElement = src.includes('avatar') || src.includes('icon') || src.includes('logo') || src.includes('button') || alt.includes('avatar') || className.includes('avatar') || className.includes('icon'); if (!isUIElement && src.includes('http')) { imageUrls.push(src); } } }); // Eliminar duplicados preservando orden return [...new Set(imageUrls)]; })(); """) as! [String] return images } /// Obtiene la información completa de un manga. /// /// Este método extrae todos los metadatos disponibles de un manga: /// título, descripción, géneros, estado de publicación, e imagen de portada. /// /// # Proceso /// 1. Carga la URL del manga en WKWebView /// 2. Espera 3 segundos a que JavaScript renderice /// 3. Ejecuta JavaScript para extraer información: /// - Título desde `

` o `.title` o `` /// - Descripción desde `<p>` con >100 caracteres /// - Géneros desde links `/genero/*` /// - Estado desde regex en body del documento /// - Cover image desde `.cover img` /// /// - Parameter mangaSlug: Slug único del manga (ej: `"one-piece_1695365223767"`) /// - Returns: Objeto `Manga` con información completa /// - Throws: `ScrapingError` si el WebView no está inicializado o falla la extracción /// /// # Example /// ```swift /// do { /// let manga = try await scraper.scrapeMangaInfo(mangaSlug: "one-piece_1695365223767") /// print("Title: \(manga.title)") /// print("Status: \(manga.displayStatus)") /// print("Genres: \(manga.genres.joined(separator: ", "))") /// } catch { /// print("Failed to scrape manga info: \(error)") /// } /// ``` func scrapeMangaInfo(mangaSlug: String) async throws -> Manga { guard let webView = webView else { throw ScrapingError.webViewNotInitialized } let url = URL(string: "https://manhwaweb.com/manga/\(mangaSlug)")! // Load URL and wait try await loadURLAndWait(url) // Extract manga info using JavaScript let mangaInfo: [String: Any] = try await webView.evaluateJavaScript(""" (function() { // Title let title = ''; const titleEl = document.querySelector('h1') || document.querySelector('.title') || document.querySelector('[class*="title"]'); if (titleEl) { title = titleEl.textContent?.trim() || ''; } if (!title) { title = document.title.replace(' - ManhwaWeb', '').replace(' - Manhwa Web', '').trim(); } // Description let description = ''; const paragraphs = document.querySelectorAll('p'); for (const p of paragraphs) { const text = p.textContent?.trim() || ''; if (text.length > 100 && !text.includes('©')) { description = text; break; } } // Genres const genres = []; const genreLinks = document.querySelectorAll('a[href*="/genero/"]'); genreLinks.forEach(link => { const genre = link.textContent?.trim(); if (genre) genres.push(genre); }); // Status let status = 'UNKNOWN'; const bodyText = document.body.textContent || ''; const statusMatch = bodyText.match(/Estado\\s*:?\\s*(PUBLICANDOSE|FINALIZADO|EN PAUSA|EN_ESPERA)/i); if (statusMatch) { status = statusMatch[1].toUpperCase().replace(' ', '_'); } // Cover image let coverImage = ''; const coverImg = document.querySelector('.cover img') || document.querySelector('[class*="cover"] img') || document.querySelector('img[alt*="cover"]'); if (coverImg) { coverImage = coverImg.src || ''; } return { title: title, description: description, genres: genres, status: status, coverImage: coverImage }; })(); """) as! [String: Any] let title = mangaInfo["title"] as? String ?? "Unknown" let description = mangaInfo["description"] as? String ?? "" let genres = mangaInfo["genres"] as? [String] ?? [] let status = mangaInfo["status"] as? String ?? "UNKNOWN" let coverImage = mangaInfo["coverImage"] as? String return Manga( slug: mangaSlug, title: title, description: description, genres: genres, status: status, url: url.absoluteString, coverImage: coverImage?.isEmpty == false ? coverImage : nil ) } // MARK: - Helper Methods /// Carga una URL en el WebView y espera a que JavaScript termine de ejecutarse. /// /// Este método es interno y usado por todos los métodos públicos de scraping. /// Carga la URL y bloquea la ejecución por un tiempo fijo para dar oportunidad /// a JavaScript de renderizar el contenido. /// /// - Parameters: /// - url: URL a cargar en el WebView /// - waitForImages: Si `true`, espera 5 segundos (para imágenes); si `false`, 3 segundos /// - Throws: `ScrapingError.webViewNotInitialized` si el WebView no está configurado private func loadURLAndWait(_ url: URL, waitForImages: Bool = false) async throws { guard let webView = webView else { throw ScrapingError.webViewNotInitialized } try await withCheckedThrowingContinuation { continuation in webView.load(URLRequest(url: url)) // Esperar a que la página cargue DispatchQueue.main.asyncAfter(deadline: .now() + (waitForImages ? 5.0 : 3.0)) { continuation.resume() } } } } // MARK: - WKNavigationDelegate /// Extensión que implementa el protocolo WKNavigationDelegate. /// /// Maneja eventos de navegación del WebView como carga completada, /// fallos de navegación, etc. Actualmente solo loggea errores para debugging. extension ManhwaWebScraper: WKNavigationDelegate { /// Se llama cuando la navegación se completa exitosamente. nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { // Navigation completed } /// Se llama cuando falla la navegación. nonisolated func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { print("Navigation failed: \(error.localizedDescription)") } /// Se llama cuando falla la navegación provisional (antes de commit). nonisolated func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { print("Provisional navigation failed: \(error.localizedDescription)") } } // MARK: - Errors /// Errores específicos que pueden ocurrir durante el scraping. /// /// `ScrapingError` define los casos de error más comunes que pueden /// ocurrir al intentar extraer contenido de manhwaweb.com. enum ScrapingError: LocalizedError { /// El WKWebView no está inicializado o es nil case webViewNotInitialized /// Error al cargar la página web (timeout, network error, etc.) case pageLoadFailed /// La página cargó pero no se encontró el contenido esperado case noContentFound /// Error al procesar/parsear el contenido extraído case parsingError /// Descripción legible del error para mostrar al usuario var errorDescription: String? { switch self { case .webViewNotInitialized: return "WebView no está inicializado" case .pageLoadFailed: return "Error al cargar la página" case .noContentFound: return "No se encontró contenido" case .parsingError: return "Error al procesar el contenido" } } }