✨ Features: - App iOS completa para leer manga sin publicidad - Scraper con WKWebView para manhwaweb.com - Sistema de descargas offline - Lector con zoom y navegación - Favoritos y progreso de lectura - Compatible con iOS 15+ y Sideloadly/3uTools 📦 Contenido: - Backend Node.js con Puppeteer (opcional) - App iOS con SwiftUI - Scraper de capítulos e imágenes - Sistema de almacenamiento local - Testing completo - Documentación exhaustiva 🧪 Prueba: Capítulo 789 de One Piece descargado exitosamente - 21 páginas descargadas - 4.68 MB total - URLs verificadas y funcionales 🎉 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
309 lines
8.8 KiB
Swift
309 lines
8.8 KiB
Swift
import Foundation
|
|
|
|
// MARK: - Manga Model
|
|
|
|
/// Representa la información completa de un manga.
|
|
///
|
|
/// `Manga` es una estructura inmutable que contiene toda la información relevante
|
|
/// sobre un manga, incluyendo título, descripción, géneros, estado de publicación
|
|
/// y metadatos adicionales como la URL de la imagen de portada.
|
|
///
|
|
/// Conforma a `Codable` para serialización/deserialización automática,
|
|
/// `Identifiable` para uso en listas de SwiftUI, y `Hashable` para comparaciones
|
|
/// y uso en sets.
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// let manga = Manga(
|
|
/// slug: "one-piece_1695365223767",
|
|
/// title: "One Piece",
|
|
/// description: "La historia de Monkey D. Luffy y su tripulación...",
|
|
/// genres: ["Acción", "Aventura", "Comedia"],
|
|
/// status: "PUBLICANDOSE",
|
|
/// url: "https://manhwaweb.com/manga/one-piece_1695365223767",
|
|
/// coverImage: "https://example.com/cover.jpg"
|
|
/// )
|
|
/// print(manga.displayStatus) // "En publicación"
|
|
/// ```
|
|
struct Manga: Codable, Identifiable, Hashable {
|
|
/// Identificador único del manga (computed, igual al slug)
|
|
let id: String { slug }
|
|
|
|
/// Slug único usado en URLs del sitio web
|
|
let slug: String
|
|
|
|
/// Título del manga
|
|
let title: String
|
|
|
|
/// Descripción o sinopsis del manga
|
|
let description: String
|
|
|
|
/// Array de géneros literarios del manga
|
|
let genres: [String]
|
|
|
|
/// Estado de publicación (crudo, sin traducir)
|
|
let status: String
|
|
|
|
/// URL completa del manga en el sitio web
|
|
let url: String
|
|
|
|
/// URL de la imagen de portada (opcional)
|
|
let coverImage: String?
|
|
|
|
/// Coding keys para mapeo JSON/codificación personalizada
|
|
enum CodingKeys: String, CodingKey {
|
|
case slug, title, description, genres, status, url, coverImage
|
|
}
|
|
|
|
/// Estado de publicación formateado para mostrar en la UI
|
|
///
|
|
/// Traduce los estados crudos del sitio web a formato legible para el usuario.
|
|
///
|
|
/// # Mapeos
|
|
/// - `"PUBLICANDOSE"` → `"En publicación"`
|
|
/// - `"FINALIZADO"` → `"Finalizado"`
|
|
/// - `"EN_PAUSA"`, `"EN_ESPERA"` → `"En pausa"`
|
|
/// - Otro → retorna el valor original sin modificar
|
|
///
|
|
/// - Returns: String con el estado traducido y formateado
|
|
var displayStatus: String {
|
|
switch status {
|
|
case "PUBLICANDOSE":
|
|
return "En publicación"
|
|
case "FINALIZADO":
|
|
return "Finalizado"
|
|
case "EN_PAUSA", "EN_ESPERA":
|
|
return "En pausa"
|
|
default:
|
|
return status
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Chapter Model
|
|
|
|
/// Representa un capítulo individual de un manga.
|
|
///
|
|
/// `Chapter` contiene información sobre un capítulo específico, incluyendo
|
|
/// su número, título, URL, y metadatos de lectura como si ha sido leído,
|
|
/// descargado, y la última página leída.
|
|
///
|
|
/// Las propiedades `isRead`, `isDownloaded`, y `lastReadPage` son mutables
|
|
/// para actualizar el estado de lectura del usuario.
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// var chapter = Chapter(
|
|
/// number: 1,
|
|
/// title: "El inicio de la aventura",
|
|
/// url: "https://manhwaweb.com/leer/one-piece/1",
|
|
/// slug: "one-piece/1"
|
|
/// )
|
|
/// chapter.isRead = true
|
|
/// chapter.lastReadPage = 15
|
|
/// print(chapter.displayNumber) // "Capítulo 1"
|
|
/// ```
|
|
struct Chapter: Codable, Identifiable, Hashable {
|
|
/// Identificador único del capítulo (computed, igual al número)
|
|
let id: Int { number }
|
|
|
|
/// Número del capítulo
|
|
let number: Int
|
|
|
|
/// Título del capítulo
|
|
let title: String
|
|
|
|
/// URL completa del capítulo en el sitio web
|
|
let url: String
|
|
|
|
/// Slug para identificar el capítulo en URLs
|
|
let slug: String
|
|
|
|
/// Indica si el capítulo ha sido marcado como leído
|
|
var isRead: Bool = false
|
|
|
|
/// Indica si el capítulo ha sido descargado localmente
|
|
var isDownloaded: Bool = false
|
|
|
|
/// Última página leída por el usuario
|
|
var lastReadPage: Int = 0
|
|
|
|
/// Número de capítulo formateado para mostrar en la UI
|
|
///
|
|
/// - Returns: String con formato "Capítulo {número}"
|
|
var displayNumber: String {
|
|
return "Capítulo \(number)"
|
|
}
|
|
|
|
/// Progreso de lectura como Double para ProgressViews
|
|
///
|
|
/// - Returns: Double representando la última página leída
|
|
var progress: Double {
|
|
return Double(lastReadPage)
|
|
}
|
|
}
|
|
|
|
// MARK: - Manga Page (Image)
|
|
|
|
/// Representa una página individual (imagen) de un capítulo.
|
|
///
|
|
/// `MangaPage` contiene la URL de una imagen de manga y su posición
|
|
/// dentro del capítulo. Puede marcar si la imagen está cacheada localmente
|
|
/// para evitar descargas redundantes.
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// let page = MangaPage(url: "https://example.com/page1.jpg", index: 0)
|
|
/// print(page.id) // URL completa
|
|
/// ```
|
|
struct MangaPage: Codable, Identifiable, Hashable {
|
|
/// Identificador único de la página (computed, igual a la URL)
|
|
let id: String { url }
|
|
|
|
/// URL completa de la imagen
|
|
let url: String
|
|
|
|
/// Índice de la página en el capítulo (0-based)
|
|
let index: Int
|
|
|
|
/// Indica si la imagen está cacheada en almacenamiento local
|
|
var isCached: Bool = false
|
|
|
|
/// URL de la versión thumbnail de la imagen
|
|
///
|
|
/// Actualmente retorna la misma URL. Futura implementación puede
|
|
/// retornar una versión optimizada/miniatura de la imagen.
|
|
///
|
|
/// - Returns: URL del thumbnail (o de la imagen completa)
|
|
var thumbnailURL: String {
|
|
// Para thumbnail podríamos usar una versión más pequeña
|
|
return url
|
|
}
|
|
}
|
|
|
|
// MARK: - Reading Progress
|
|
|
|
/// Almacena el progreso de lectura de un usuario.
|
|
///
|
|
/// `ReadingProgress` registra qué página de qué capítulo de qué manga
|
|
/// leyó el usuario, junto con un timestamp para sincronización.
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// let progress = ReadingProgress(
|
|
/// mangaSlug: "one-piece",
|
|
/// chapterNumber: 1,
|
|
/// pageNumber: 15,
|
|
/// timestamp: Date()
|
|
/// )
|
|
/// if progress.isCompleted {
|
|
/// print("Capítulo completado")
|
|
/// }
|
|
/// ```
|
|
struct ReadingProgress: Codable {
|
|
/// Slug del manga que se está leyendo
|
|
let mangaSlug: String
|
|
|
|
/// Número del capítulo
|
|
let chapterNumber: Int
|
|
|
|
/// Número de página actual
|
|
let pageNumber: Int
|
|
|
|
/// Fecha y hora en que se guardó el progreso
|
|
let timestamp: Date
|
|
|
|
/// Indica si el capítulo se considera completado
|
|
///
|
|
/// Un capítulo se considera completado si el usuario ha leído
|
|
/// más de 5 páginas. Este umbral evita marcar como completados
|
|
/// capítulos que el usuario solo hojeó.
|
|
///
|
|
/// - Returns: `true` si `pageNumber > 5`, `false` en caso contrario
|
|
var isCompleted: Bool {
|
|
// Considerar completado si leyó más de 5 páginas
|
|
return pageNumber > 5
|
|
}
|
|
}
|
|
|
|
// MARK: - Downloaded Chapter
|
|
|
|
/// Representa un capítulo descargado localmente en el dispositivo.
|
|
///
|
|
/// `DownloadedChapter` contiene metadata sobre un capítulo que ha sido
|
|
/// descargado, incluyendo todas sus páginas, fecha de descarga, y tamaño
|
|
/// total en disco.
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// let downloaded = DownloadedChapter(
|
|
/// mangaSlug: "one-piece",
|
|
/// mangaTitle: "One Piece",
|
|
/// chapterNumber: 1,
|
|
/// pages: [page1, page2, page3],
|
|
/// downloadedAt: Date()
|
|
/// )
|
|
/// print(downloaded.displayTitle) // "One Piece - Capítulo 1"
|
|
/// ```
|
|
struct DownloadedChapter: Codable, Identifiable {
|
|
/// Identificador único compuesto por manga-slug y número de capítulo
|
|
let id: String { "\(mangaSlug)-chapter\(chapterNumber)" }
|
|
|
|
/// Slug del manga
|
|
let mangaSlug: String
|
|
|
|
/// Título del manga
|
|
let mangaTitle: String
|
|
|
|
/// Número del capítulo
|
|
let chapterNumber: Int
|
|
|
|
/// Array de páginas del capítulo
|
|
let pages: [MangaPage]
|
|
|
|
/// Fecha y hora de descarga
|
|
let downloadedAt: Date
|
|
|
|
/// Tamaño total del capítulo en bytes
|
|
var totalSize: Int64 = 0
|
|
|
|
/// Título formateado para mostrar en la UI
|
|
///
|
|
/// - Returns: String con formato "{MangaTitle} - Capítulo {number}"
|
|
var displayTitle: String {
|
|
"\(mangaTitle) - Capítulo \(chapterNumber)"
|
|
}
|
|
}
|
|
|
|
// MARK: - API Response Models
|
|
|
|
/// Respuesta de API que contiene una lista de mangas.
|
|
///
|
|
/// Usado para respuestas paginadas o listas completas de mangas
|
|
/// desde un backend opcional.
|
|
struct MangaListResponse: Codable {
|
|
/// Array de mangas en la respuesta
|
|
let mangas: [Manga]
|
|
|
|
/// Número total de mangas (útil para paginación)
|
|
let total: Int
|
|
}
|
|
|
|
/// Respuesta de API que contiene la lista de capítulos de un manga.
|
|
struct ChapterListResponse: Codable {
|
|
/// Array de capítulos del manga
|
|
let chapters: [Chapter]
|
|
|
|
/// Slug del manga al que pertenecen los capítulos
|
|
let mangaSlug: String
|
|
}
|
|
|
|
/// Respuesta de API con las URLs de imágenes de un capítulo.
|
|
struct ChapterImagesResponse: Codable {
|
|
/// Array de URLs de imágenes en orden
|
|
let images: [String]
|
|
|
|
/// Slug del capítulo
|
|
let chapterSlug: String
|
|
}
|