Initial commit: MangaReader iOS App
✨ 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>
This commit is contained in:
992
docs/API.md
Normal file
992
docs/API.md
Normal file
@@ -0,0 +1,992 @@
|
||||
# API Documentation - MangaReader
|
||||
|
||||
Este documento proporciona la documentación completa de la API de MangaReader, incluyendo modelos de datos, servicios, ViewModels y sus responsabilidades.
|
||||
|
||||
## Tabla de Contenidos
|
||||
|
||||
- [Modelos de Datos](#modelos-de-datos)
|
||||
- [Servicios](#servicios)
|
||||
- [ViewModels](#viewmodels)
|
||||
- [Views](#views)
|
||||
- [Errores y Manejo de Excepciones](#errores-y-manejo-de-excepciones)
|
||||
|
||||
---
|
||||
|
||||
## Modelos de Datos
|
||||
|
||||
Los modelos de datos se encuentran en `ios-app/Sources/Models/Manga.swift`. Son estructuras inmutables que conforman a protocolos Swift estándar para serialización e identificación.
|
||||
|
||||
### Manga
|
||||
|
||||
Representa la información completa de un manga.
|
||||
|
||||
```swift
|
||||
struct Manga: Codable, Identifiable, Hashable {
|
||||
let id: String { slug }
|
||||
let slug: String
|
||||
let title: String
|
||||
let description: String
|
||||
let genres: [String]
|
||||
let status: String
|
||||
let url: String
|
||||
let coverImage: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case slug, title, description, genres, status, url, coverImage
|
||||
}
|
||||
|
||||
var displayStatus: String { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Propiedades
|
||||
|
||||
| Propiedad | Tipo | Descripción | Ejemplo |
|
||||
|-----------|------|-------------|---------|
|
||||
| `id` | `String` | Identificador único (computed, igual a `slug`) | `"one-piece_1695365223767"` |
|
||||
| `slug` | `String` | Slug único del manga usado en URLs | `"one-piece_1695365223767"` |
|
||||
| `title` | `String` | Título del manga | `"One Piece"` |
|
||||
| `description` | `String` | Descripción o sinopsis | `"La historia de piratas..."` |
|
||||
| `genres` | `[String]` | Array de géneros literarios | `["Acción", "Aventura"]` |
|
||||
| `status` | `String` | Estado de publicación (crudo) | `"PUBLICANDOSE"` |
|
||||
| `url` | `String` | URL completa del manga | `"https://manhwaweb.com/manga/..."` |
|
||||
| `coverImage` | `String?` | URL de imagen de portada (opcional) | `"https://..."` |
|
||||
|
||||
#### Métodos Computados
|
||||
|
||||
**`displayStatus: String`**
|
||||
- Retorna el estado traducido y formateado para mostrar en UI
|
||||
- Mapeos:
|
||||
- `"PUBLICANDOSE"` → `"En publicación"`
|
||||
- `"FINALIZADO"` → `"Finalizado"`
|
||||
- `"EN_PAUSA"`, `"EN_ESPERA"` → `"En pausa"`
|
||||
- Otro → retorna valor original
|
||||
|
||||
#### Ejemplo de Uso
|
||||
|
||||
```swift
|
||||
let manga = Manga(
|
||||
slug: "one-piece_1695365223767",
|
||||
title: "One Piece",
|
||||
description: "La historia de Monkey D. Luffy...",
|
||||
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"
|
||||
print(manga.id) // "one-piece_1695365223767"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Chapter
|
||||
|
||||
Representa un capítulo individual de un manga.
|
||||
|
||||
```swift
|
||||
struct Chapter: Codable, Identifiable, Hashable {
|
||||
let id: Int { number }
|
||||
let number: Int
|
||||
let title: String
|
||||
let url: String
|
||||
let slug: String
|
||||
var isRead: Bool = false
|
||||
var isDownloaded: Bool = false
|
||||
var lastReadPage: Int = 0
|
||||
|
||||
var displayNumber: String { ... }
|
||||
var progress: Double { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Propiedades
|
||||
|
||||
| Propiedad | Tipo | Descripción | Ejemplo |
|
||||
|-----------|------|-------------|---------|
|
||||
| `id` | `Int` | Identificador único (computed, igual a `number`) | `1` |
|
||||
| `number` | `Int` | Número del capítulo | `1` |
|
||||
| `title` | `String` | Título del capítulo | `"El inicio de la aventura"` |
|
||||
| `url` | `String` | URL completa del capítulo | `"https://manhwaweb.com/leer/..."` |
|
||||
| `slug` | `String` | Slug para identificar el capítulo | `"one-piece/capitulo-1"` |
|
||||
| `isRead` | `Bool` | Estado de lectura (mutable) | `false` |
|
||||
| `isDownloaded` | `Bool` | Estado de descarga (mutable) | `false` |
|
||||
| `lastReadPage` | `Int` | Última página leída (mutable) | `5` |
|
||||
|
||||
#### Métodos Computados
|
||||
|
||||
**`displayNumber: String`**
|
||||
- Retorna string formateado para mostrar
|
||||
- Formato: `"Capítulo {number}"`
|
||||
|
||||
**`progress: Double`**
|
||||
- Retorna progreso como `Double` para ProgressViews
|
||||
- Valor: `Double(lastReadPage)`
|
||||
|
||||
#### Ejemplo de Uso
|
||||
|
||||
```swift
|
||||
var chapter = Chapter(
|
||||
number: 1,
|
||||
title: "El inicio",
|
||||
url: "https://manhwaweb.com/leer/one-piece/1",
|
||||
slug: "one-piece/1"
|
||||
)
|
||||
|
||||
chapter.isRead = true
|
||||
chapter.lastReadPage = 15
|
||||
|
||||
print(chapter.displayNumber) // "Capítulo 1"
|
||||
print(chapter.progress) // 15.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MangaPage
|
||||
|
||||
Representa una página individual (imagen) de un capítulo.
|
||||
|
||||
```swift
|
||||
struct MangaPage: Codable, Identifiable, Hashable {
|
||||
let id: String { url }
|
||||
let url: String
|
||||
let index: Int
|
||||
var isCached: Bool = false
|
||||
|
||||
var thumbnailURL: String { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Propiedades
|
||||
|
||||
| Propiedad | Tipo | Descripción | Ejemplo |
|
||||
|-----------|------|-------------|---------|
|
||||
| `id` | `String` | Identificador único (computed, igual a `url`) | `"https://..."` |
|
||||
| `url` | `String` | URL completa de la imagen | `"https://example.com/page1.jpg"` |
|
||||
| `index` | `Int` | Índice de la página en el capítulo | `0` |
|
||||
| `isCached` | `Bool` | Estado de cache local (mutable) | `false` |
|
||||
|
||||
#### Métodos Computados
|
||||
|
||||
**`thumbnailURL: String`**
|
||||
- Actualmente retorna la misma URL
|
||||
- Futuro: implementar versión thumbnail optimizada
|
||||
|
||||
---
|
||||
|
||||
### ReadingProgress
|
||||
|
||||
Almacena el progreso de lectura de un usuario.
|
||||
|
||||
```swift
|
||||
struct ReadingProgress: Codable {
|
||||
let mangaSlug: String
|
||||
let chapterNumber: Int
|
||||
let pageNumber: Int
|
||||
let timestamp: Date
|
||||
|
||||
var isCompleted: Bool { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Propiedades
|
||||
|
||||
| Propiedad | Tipo | Descripción |
|
||||
|-----------|------|-------------|
|
||||
| `mangaSlug` | `String` | Slug del manga |
|
||||
| `chapterNumber` | `Int` | Número del capítulo |
|
||||
| `pageNumber` | `Int` | Página actual |
|
||||
| `timestamp` | `Date` | Fecha/hora de lectura |
|
||||
|
||||
#### Métodos Computados
|
||||
|
||||
**`isCompleted: Bool`**
|
||||
- Retorna `true` si el usuario leyó más de 5 páginas
|
||||
- Lógica: `return pageNumber > 5`
|
||||
|
||||
---
|
||||
|
||||
### DownloadedChapter
|
||||
|
||||
Representa un capítulo descargado localmente.
|
||||
|
||||
```swift
|
||||
struct DownloadedChapter: Codable, Identifiable {
|
||||
let id: String { "\(mangaSlug)-chapter\(chapterNumber)" }
|
||||
let mangaSlug: String
|
||||
let mangaTitle: String
|
||||
let chapterNumber: Int
|
||||
let pages: [MangaPage]
|
||||
let downloadedAt: Date
|
||||
var totalSize: Int64 = 0
|
||||
|
||||
var displayTitle: String { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Propiedades
|
||||
|
||||
| Propiedad | Tipo | Descripción |
|
||||
|-----------|------|-------------|
|
||||
| `id` | `String` | ID compuesto único |
|
||||
| `mangaSlug` | `String` | Slug del manga |
|
||||
| `mangaTitle` | `String` | Título del manga |
|
||||
| `chapterNumber` | `Int` | Número del capítulo |
|
||||
| `pages` | `[MangaPage]` | Array de páginas |
|
||||
| `downloadedAt` | `Date` | Fecha de descarga |
|
||||
| `totalSize` | `Int64` | Tamaño total en bytes |
|
||||
|
||||
---
|
||||
|
||||
## Servicios
|
||||
|
||||
Los servicios encapsulan la lógica de negocio y se encuentran en `ios-app/Sources/Services/`.
|
||||
|
||||
### ManhwaWebScraper
|
||||
|
||||
Servicio responsable del scraping de contenido web desde manhwaweb.com.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
class ManhwaWebScraper: NSObject, ObservableObject
|
||||
```
|
||||
|
||||
#### Propiedades
|
||||
|
||||
| Propiedad | Tipo | Acceso | Descripción |
|
||||
|-----------|------|--------|-------------|
|
||||
| `shared` | `ManhwaWebScraper` | `static` | Instancia singleton compartida |
|
||||
| `webView` | `WKWebView?` | `private` | WebView para ejecutar JavaScript |
|
||||
|
||||
#### Métodos Públicos
|
||||
|
||||
##### `scrapeMangaInfo(mangaSlug:)`
|
||||
|
||||
Obtiene la información completa de un manga.
|
||||
|
||||
**Firma:**
|
||||
```swift
|
||||
func scrapeMangaInfo(mangaSlug: String) async throws -> Manga
|
||||
```
|
||||
|
||||
**Parámetros:**
|
||||
- `mangaSlug`: `String` - Slug único del manga
|
||||
|
||||
**Retorna:**
|
||||
- `Manga` - Objeto con información completa del manga
|
||||
|
||||
**Errors:**
|
||||
- `ScrapingError.webViewNotInitialized`
|
||||
- `ScrapingError.pageLoadFailed`
|
||||
- `ScrapingError.noContentFound`
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
do {
|
||||
let manga = try await ManhwaWebScraper.shared.scrapeMangaInfo(
|
||||
mangaSlug: "one-piece_1695365223767"
|
||||
)
|
||||
print("Manga: \(manga.title)")
|
||||
} catch {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
}
|
||||
```
|
||||
|
||||
**Proceso Interno:**
|
||||
1. Construye URL: `https://manhwaweb.com/manga/{slug}`
|
||||
2. Carga URL en WKWebView
|
||||
3. Espera 3 segundos a que JavaScript renderice
|
||||
4. Ejecuta JavaScript para extraer:
|
||||
- Título (de `<h1>` o `<title>`)
|
||||
- Descripción (de `<p>` con >100 caracteres)
|
||||
- Géneros (de links `/genero/*`)
|
||||
- Estado (via regex)
|
||||
- Cover image (de `.cover img`)
|
||||
5. Parsea resultado a `Manga`
|
||||
|
||||
---
|
||||
|
||||
##### `scrapeChapters(mangaSlug:)`
|
||||
|
||||
Obtiene la lista de capítulos de un manga.
|
||||
|
||||
**Firma:**
|
||||
```swift
|
||||
func scrapeChapters(mangaSlug: String) async throws -> [Chapter]
|
||||
```
|
||||
|
||||
**Parámetros:**
|
||||
- `mangaSlug`: `String` - Slug del manga
|
||||
|
||||
**Retorna:**
|
||||
- `[Chapter]` - Array de capítulos ordenados descendente
|
||||
|
||||
**Errors:**
|
||||
- `ScrapingError.webViewNotInitialized`
|
||||
- `ScrapingError.pageLoadFailed`
|
||||
- `ScrapingError.parsingError`
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
do {
|
||||
let chapters = try await ManhwaWebScraper.shared.scrapeChapters(
|
||||
mangaSlug: "one-piece_1695365223767"
|
||||
)
|
||||
print("Found \(chapters.count) chapters")
|
||||
} catch {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
}
|
||||
```
|
||||
|
||||
**Proceso Interno:**
|
||||
1. Carga página del manga
|
||||
2. Ejecuta JavaScript que:
|
||||
- Busca todos los links `/leer/*`
|
||||
- Extrae número de capítulo via regex
|
||||
- Filtra duplicados
|
||||
- Ordena descendente
|
||||
3. Parsea a array de `Chapter`
|
||||
|
||||
---
|
||||
|
||||
##### `scrapeChapterImages(chapterSlug:)`
|
||||
|
||||
Obtiene las URLs de las imágenes de un capítulo.
|
||||
|
||||
**Firma:**
|
||||
```swift
|
||||
func scrapeChapterImages(chapterSlug: String) async throws -> [String]
|
||||
```
|
||||
|
||||
**Parámetros:**
|
||||
- `chapterSlug`: `String` - Slug del capítulo
|
||||
|
||||
**Retorna:**
|
||||
- `[String]` - Array de URLs de imágenes en orden
|
||||
|
||||
**Errors:**
|
||||
- `ScrapingError.webViewNotInitialized`
|
||||
- `ScrapingError.pageLoadFailed`
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
do {
|
||||
let images = try await ManhwaWebScraper.shared.scrapeChapterImages(
|
||||
chapterSlug: "one-piece/1"
|
||||
)
|
||||
print("Found \(images.count) pages")
|
||||
for (index, imageUrl) in images.enumerated() {
|
||||
print("Page \(index + 1): \(imageUrl)")
|
||||
}
|
||||
} catch {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
}
|
||||
```
|
||||
|
||||
**Proceso Interno:**
|
||||
1. Carga URL del capítulo
|
||||
2. Espera 5 segundos (para imágenes)
|
||||
3. Ejecuta JavaScript que:
|
||||
- Selecciona todas las etiquetas `<img>`
|
||||
- Filtra elementos de UI (avatars, icons, logos)
|
||||
- Elimina duplicados
|
||||
4. Retorna array de URLs
|
||||
|
||||
---
|
||||
|
||||
#### Métodos Privados
|
||||
|
||||
##### `setupWebView()`
|
||||
|
||||
Configura el WKWebView con preferencias optimizadas.
|
||||
|
||||
##### `loadURLAndWait(_:waitForImages:)`
|
||||
|
||||
Carga una URL y espera a que JavaScript termine de renderizar.
|
||||
|
||||
**Parámetros:**
|
||||
- `url`: `URL` - URL a cargar
|
||||
- `waitForImages`: `Bool` - Si `true`, espera 5 segundos; si `false`, 3 segundos
|
||||
|
||||
---
|
||||
|
||||
### StorageService
|
||||
|
||||
Servicio responsable del almacenamiento local de datos.
|
||||
|
||||
```swift
|
||||
class StorageService
|
||||
```
|
||||
|
||||
#### Propiedades
|
||||
|
||||
| Propiedad | Tipo | Acceso | Descripción |
|
||||
|-----------|------|--------|-------------|
|
||||
| `shared` | `StorageService` | `static` | Instancia singleton compartida |
|
||||
| `documentsDirectory` | `URL` | `private` | Directorio de documentos |
|
||||
| `chaptersDirectory` | `URL` | `private` | Directorio de capítulos |
|
||||
|
||||
#### Gestión de Favoritos
|
||||
|
||||
##### `getFavorites()`
|
||||
|
||||
```swift
|
||||
func getFavorites() -> [String]
|
||||
```
|
||||
Retorna array de slugs de mangas favoritos.
|
||||
|
||||
##### `saveFavorite(mangaSlug:)`
|
||||
|
||||
```swift
|
||||
func saveFavorite(mangaSlug: String)
|
||||
```
|
||||
Guarda un manga como favorito (no duplica si ya existe).
|
||||
|
||||
##### `removeFavorite(mangaSlug:)`
|
||||
|
||||
```swift
|
||||
func removeFavorite(mangaSlug: String)
|
||||
```
|
||||
Elimina un manga de favoritos.
|
||||
|
||||
##### `isFavorite(mangaSlug:)`
|
||||
|
||||
```swift
|
||||
func isFavorite(mangaSlug: String) -> Bool
|
||||
```
|
||||
Verifica si un manga es favorito.
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
let storage = StorageService.shared
|
||||
|
||||
// Guardar favorito
|
||||
storage.saveFavorite(mangaSlug: "one-piece_1695365223767")
|
||||
|
||||
// Verificar
|
||||
if storage.isFavorite(mangaSlug: "one-piece_1695365223767") {
|
||||
print("Es favorito")
|
||||
}
|
||||
|
||||
// Listar todos
|
||||
let favorites = storage.getFavorites()
|
||||
print("Tienes \(favorites.count) favoritos")
|
||||
|
||||
// Eliminar
|
||||
storage.removeFavorite(mangaSlug: "one-piece_1695365223767")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Gestión de Progreso de Lectura
|
||||
|
||||
##### `saveReadingProgress(_:)`
|
||||
|
||||
```swift
|
||||
func saveReadingProgress(_ progress: ReadingProgress)
|
||||
```
|
||||
Guarda o actualiza el progreso de lectura.
|
||||
|
||||
##### `getReadingProgress(mangaSlug:chapterNumber:)`
|
||||
|
||||
```swift
|
||||
func getReadingProgress(mangaSlug: String, chapterNumber: Int) -> ReadingProgress?
|
||||
```
|
||||
Retorna el progreso de un capítulo específico.
|
||||
|
||||
##### `getAllReadingProgress()`
|
||||
|
||||
```swift
|
||||
func getAllReadingProgress() -> [ReadingProgress]
|
||||
```
|
||||
Retorna todo el progreso guardado.
|
||||
|
||||
##### `getLastReadChapter(mangaSlug:)`
|
||||
|
||||
```swift
|
||||
func getLastReadChapter(mangaSlug: String) -> ReadingProgress?
|
||||
```
|
||||
Retorna el capítulo más reciente leído de un manga.
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
let storage = StorageService.shared
|
||||
|
||||
// Guardar progreso
|
||||
let progress = ReadingProgress(
|
||||
mangaSlug: "one-piece_1695365223767",
|
||||
chapterNumber: 1,
|
||||
pageNumber: 15,
|
||||
timestamp: Date()
|
||||
)
|
||||
storage.saveReadingProgress(progress)
|
||||
|
||||
// Recuperar progreso
|
||||
if let savedProgress = storage.getReadingProgress(
|
||||
mangaSlug: "one-piece_1695365223767",
|
||||
chapterNumber: 1
|
||||
) {
|
||||
print("Última página: \(savedProgress.pageNumber)")
|
||||
}
|
||||
|
||||
// Último capítulo leído
|
||||
if let lastChapter = storage.getLastReadChapter(mangaSlug: "one-piece_1695365223767") {
|
||||
print("Último capítulo: \(lastChapter.chapterNumber)")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Gestión de Capítulos Descargados
|
||||
|
||||
##### `saveDownloadedChapter(_:)`
|
||||
|
||||
```swift
|
||||
func saveDownloadedChapter(_ chapter: DownloadedChapter)
|
||||
```
|
||||
Guarda metadata de un capítulo descargado.
|
||||
|
||||
##### `getDownloadedChapters()`
|
||||
|
||||
```swift
|
||||
func getDownloadedChapters() -> [DownloadedChapter]
|
||||
```
|
||||
Retorna todos los capítulos descargados.
|
||||
|
||||
##### `isChapterDownloaded(mangaSlug:chapterNumber:)`
|
||||
|
||||
```swift
|
||||
func isChapterDownloaded(mangaSlug: String, chapterNumber: Int) -> Bool
|
||||
```
|
||||
Verifica si un capítulo está descargado.
|
||||
|
||||
##### `deleteDownloadedChapter(mangaSlug:chapterNumber:)`
|
||||
|
||||
```swift
|
||||
func deleteDownloadedChapter(mangaSlug: String, chapterNumber: Int)
|
||||
```
|
||||
Elimina un capítulo descargado (archivos + metadata).
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
let storage = StorageService.shared
|
||||
|
||||
// Verificar si está descargado
|
||||
if storage.isChapterDownloaded(
|
||||
mangaSlug: "one-piece_1695365223767",
|
||||
chapterNumber: 1
|
||||
) {
|
||||
print("Capítulo ya descargado")
|
||||
} else {
|
||||
print("Capítulo no descargado")
|
||||
}
|
||||
|
||||
// Eliminar capítulo
|
||||
storage.deleteDownloadedChapter(
|
||||
mangaSlug: "one-piece_1695365223767",
|
||||
chapterNumber: 1
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Gestión de Imágenes
|
||||
|
||||
##### `saveImage(_:mangaSlug:chapterNumber:pageIndex:)`
|
||||
|
||||
```swift
|
||||
func saveImage(_ image: UIImage, mangaSlug: String, chapterNumber: Int, pageIndex: Int) async throws -> URL
|
||||
```
|
||||
Guarda una imagen en disco local.
|
||||
|
||||
**Parámetros:**
|
||||
- `image`: `UIImage` - Imagen a guardar
|
||||
- `mangaSlug`: `String` - Slug del manga
|
||||
- `chapterNumber`: `Int` - Número del capítulo
|
||||
- `pageIndex`: `Int` - Índice de la página
|
||||
|
||||
**Retorna:**
|
||||
- `URL` - Ruta del archivo guardado
|
||||
|
||||
**Errors:**
|
||||
- Error si no se puede convertir o guardar
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
let storage = StorageService.shared
|
||||
|
||||
do {
|
||||
let imageURL = try await storage.saveImage(
|
||||
image: myUIImage,
|
||||
mangaSlug: "one-piece_1695365223767",
|
||||
chapterNumber: 1,
|
||||
pageIndex: 0
|
||||
)
|
||||
print("Imagen guardada en: \(imageURL.path)")
|
||||
} catch {
|
||||
print("Error guardando imagen: \(error)")
|
||||
}
|
||||
```
|
||||
|
||||
##### `loadImage(mangaSlug:chapterNumber:pageIndex:)`
|
||||
|
||||
```swift
|
||||
func loadImage(mangaSlug: String, chapterNumber: Int, pageIndex: Int) -> UIImage?
|
||||
```
|
||||
Carga una imagen desde disco.
|
||||
|
||||
##### `getImageURL(mangaSlug:chapterNumber:pageIndex:)`
|
||||
|
||||
```swift
|
||||
func getImageURL(mangaSlug: String, chapterNumber: Int, pageIndex: Int) -> URL?
|
||||
```
|
||||
Retorna la URL local de una imagen si existe.
|
||||
|
||||
---
|
||||
|
||||
#### Gestión de Almacenamiento
|
||||
|
||||
##### `getStorageSize()`
|
||||
|
||||
```swift
|
||||
func getStorageSize() -> Int64
|
||||
```
|
||||
Retorna el tamaño total usado en bytes.
|
||||
|
||||
##### `clearAllDownloads()`
|
||||
|
||||
```swift
|
||||
func clearAllDownloads()
|
||||
```
|
||||
Elimina todos los capítulos descargados.
|
||||
|
||||
##### `formatFileSize(_:)`
|
||||
|
||||
```swift
|
||||
func formatFileSize(_ bytes: Int64) -> String
|
||||
```
|
||||
Formatea bytes a string legible (KB, MB, GB).
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
let storage = StorageService.shared
|
||||
|
||||
// Obtener tamaño usado
|
||||
let size = storage.getStorageSize()
|
||||
let formatted = storage.formatFileSize(size)
|
||||
print("Almacenamiento usado: \(formatted)")
|
||||
|
||||
// Limpiar todo
|
||||
storage.clearAllDownloads()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ViewModels
|
||||
|
||||
Los ViewModels coordinan entre Services y Views.
|
||||
|
||||
### MangaListViewModel
|
||||
|
||||
ViewModel para la lista principal de mangas.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
class MangaListViewModel: ObservableObject
|
||||
```
|
||||
|
||||
#### Propiedades Publicadas
|
||||
|
||||
| Propiedad | Tipo | Descripción |
|
||||
|-----------|------|-------------|
|
||||
| `mangas` | `[Manga]` | Lista de mangas cargados |
|
||||
| `isLoading` | `Bool` | Indicador de carga |
|
||||
| `searchText` | `String` | Texto de búsqueda |
|
||||
| `filter` | `MangaFilter` | Filtro actual (all/favorites/downloaded) |
|
||||
| `newMangaSlug` | `String` | Slug del nuevo manga a agregar |
|
||||
|
||||
#### Métodos Públicos
|
||||
|
||||
##### `loadMangas()`
|
||||
|
||||
```swift
|
||||
func loadMangas() async
|
||||
```
|
||||
Carga los mangas guardados en favoritos.
|
||||
|
||||
##### `addManga(_:)`
|
||||
|
||||
```swift
|
||||
func addManga(_ slug: String) async
|
||||
```
|
||||
Agrega un nuevo manga mediante scraping.
|
||||
|
||||
#### Propiedades Computadas
|
||||
|
||||
##### `filteredMangas`
|
||||
|
||||
```swift
|
||||
var filteredMangas: [Manga]
|
||||
```
|
||||
Retorna mangas filtrados por búsqueda y categoría.
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
@StateObject private var viewModel = MangaListViewModel()
|
||||
|
||||
// Cargar mangas
|
||||
await viewModel.loadMangas()
|
||||
|
||||
// Agregar manga
|
||||
await viewModel.addManga("one-piece_1695365223767")
|
||||
|
||||
// Filtrar
|
||||
viewModel.filter = .favorites
|
||||
viewModel.searchText = "one"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MangaDetailViewModel
|
||||
|
||||
ViewModel para el detalle de un manga.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
class MangaDetailViewModel: ObservableObject
|
||||
```
|
||||
|
||||
#### Propiedades Publicadas
|
||||
|
||||
| Propiedad | Tipo | Descripción |
|
||||
|-----------|------|-------------|
|
||||
| `chapters` | `[Chapter]` | Lista de capítulos |
|
||||
| `isLoadingChapters` | `Bool` | Indicador de carga |
|
||||
| `isFavorite` | `Bool` | Estado de favorito |
|
||||
| `selectedChapter` | `Chapter?` | Capítulo seleccionado |
|
||||
| `showingDownloadAll` | `Bool` | Mostrar diálogo de descarga |
|
||||
|
||||
#### Métodos Públicos
|
||||
|
||||
##### `loadChapters()`
|
||||
|
||||
```swift
|
||||
func loadChapters() async
|
||||
```
|
||||
Carga los capítulos del manga.
|
||||
|
||||
##### `toggleFavorite()`
|
||||
|
||||
```swift
|
||||
func toggleFavorite()
|
||||
```
|
||||
Alterna el estado de favorito.
|
||||
|
||||
##### `downloadAllChapters()`
|
||||
|
||||
```swift
|
||||
func downloadAllChapters()
|
||||
```
|
||||
Inicia descarga de todos los capítulos.
|
||||
|
||||
##### `downloadLastChapters(count:)`
|
||||
|
||||
```swift
|
||||
func downloadLastChapters(count: Int)
|
||||
```
|
||||
Descarga los últimos N capítulos.
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
@StateObject private var viewModel = MangaDetailViewModel(manga: manga)
|
||||
|
||||
// Cargar capítulos
|
||||
await viewModel.loadChapters()
|
||||
|
||||
// Marcar favorito
|
||||
viewModel.toggleFavorite()
|
||||
|
||||
// Descargar últimos 10
|
||||
viewModel.downloadLastChapters(count: 10)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ReaderViewModel
|
||||
|
||||
ViewModel para el lector de capítulos.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
class ReaderViewModel: ObservableObject
|
||||
```
|
||||
|
||||
#### Propiedades Publicadas
|
||||
|
||||
| Propiedad | Tipo | Descripción |
|
||||
|-----------|------|-------------|
|
||||
| `pages` | `[MangaPage]` | Lista de páginas |
|
||||
| `currentPage` | `Int` | Página actual |
|
||||
| `isLoading` | `Bool` | Indicador de carga |
|
||||
| `showError` | `Bool` | Mostrar error |
|
||||
| `showControls` | `Bool` | Mostrar controles UI |
|
||||
| `isFavorite` | `Bool` | Estado de favorito |
|
||||
| `isDownloaded` | `Bool` | Capítulo descargado |
|
||||
| `backgroundColor` | `Color` | Color de fondo |
|
||||
| `readingMode` | `ReadingMode` | Modo de lectura |
|
||||
|
||||
#### Métodos Públicos
|
||||
|
||||
##### `loadPages()`
|
||||
|
||||
```swift
|
||||
func loadPages() async
|
||||
```
|
||||
Carga las páginas del capítulo (desde local o web).
|
||||
|
||||
##### `cachePage(_:image:)`
|
||||
|
||||
```swift
|
||||
func cachePage(_ page: MangaPage, image: Image) async
|
||||
```
|
||||
Cachea una página localmente (TODO: implementar).
|
||||
|
||||
##### `toggleFavorite()`
|
||||
|
||||
```swift
|
||||
func toggleFavorite()
|
||||
```
|
||||
Alterna favorito del manga actual.
|
||||
|
||||
##### `cycleBackgroundColor()`
|
||||
|
||||
```swift
|
||||
func cycleBackgroundColor()
|
||||
```
|
||||
Cicla entre colores de fondo (blanco/negro/sepia).
|
||||
|
||||
#### Propiedades Computadas
|
||||
|
||||
- `currentPageIndex`: `Int` - Índice de página actual
|
||||
- `totalPages`: `Int` - Total de páginas
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
@StateObject private var viewModel = ReaderViewModel(manga: manga, chapter: chapter)
|
||||
|
||||
// Cargar páginas
|
||||
await viewModel.loadPages()
|
||||
|
||||
// Ir a página específica
|
||||
viewModel.currentPage = 10
|
||||
|
||||
// Cambiar fondo
|
||||
viewModel.cycleBackgroundColor()
|
||||
|
||||
// Cambiar modo lectura
|
||||
viewModel.readingMode = .horizontal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Views
|
||||
|
||||
Las vistas son componentes SwiftUI que presentan la UI.
|
||||
|
||||
### ContentView
|
||||
|
||||
Vista principal que muestra la lista de mangas.
|
||||
|
||||
**Componentes:**
|
||||
- `MangaRowView`: Fila individual de manga
|
||||
- `MangaListViewModel`: ViewModel asociado
|
||||
|
||||
**Funcionalidades:**
|
||||
- Búsqueda de mangas
|
||||
- Filtros (todos/favoritos/descargados)
|
||||
- Agregar manga manualmente
|
||||
- Pull-to-refresh
|
||||
|
||||
### MangaDetailView
|
||||
|
||||
Vista de detalle de un manga específico.
|
||||
|
||||
**Componentes:**
|
||||
- `ChapterRowView`: Fila de capítulo
|
||||
- `FlowLayout`: Layout de géneros
|
||||
- `MangaDetailViewModel`: ViewModel asociado
|
||||
|
||||
**Funcionalidades:**
|
||||
- Mostrar información del manga
|
||||
- Listar capítulos
|
||||
- Marcar favorito
|
||||
- Descargar capítulos
|
||||
|
||||
### ReaderView
|
||||
|
||||
Vista de lectura de capítulos.
|
||||
|
||||
**Componentes:**
|
||||
- `PageView`: Vista de página individual
|
||||
- `ReaderViewModel`: ViewModel asociado
|
||||
|
||||
**Funcionalidades:**
|
||||
- Mostrar páginas con zoom/pan
|
||||
- Navegación entre páginas
|
||||
- Configurar fondo
|
||||
- Cambiar modo de lectura
|
||||
- Slider de navegación
|
||||
|
||||
---
|
||||
|
||||
## Errores y Manejo de Excepciones
|
||||
|
||||
### ScrapingError
|
||||
|
||||
Errores específicos del scraper.
|
||||
|
||||
```swift
|
||||
enum ScrapingError: LocalizedError {
|
||||
case webViewNotInitialized
|
||||
case pageLoadFailed
|
||||
case noContentFound
|
||||
case parsingError
|
||||
|
||||
var errorDescription: String? { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Casos
|
||||
|
||||
| Error | Descripción | Mensaje (Español) |
|
||||
|-------|-------------|-------------------|
|
||||
| `webViewNotInitialized` | WKWebView no configurado | "WebView no está inicializado" |
|
||||
| `pageLoadFailed` | Error cargando página | "Error al cargar la página" |
|
||||
| `noContentFound` | No se encontró contenido | "No se encontró contenido" |
|
||||
| `parsingError` | Error procesando datos | "Error al procesar el contenido" |
|
||||
|
||||
### Estrategias de Manejo de Errores
|
||||
|
||||
1. **ViewModels**: Capturan errores del scraper
|
||||
2. **Views**: Muestran alertas al usuario
|
||||
3. **Servicios**: Propagan errores con `throws`
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
do {
|
||||
let manga = try await scraper.scrapeMangaInfo(mangaSlug: slug)
|
||||
// Manejar éxito
|
||||
} catch ScrapingError.webViewNotInitialized {
|
||||
// Mostrar alerta específica
|
||||
errorMessage = "Error de configuración"
|
||||
} catch {
|
||||
// Error genérico
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Última actualización**: Febrero 2026
|
||||
**Versión**: 1.0.0
|
||||
642
docs/ARCHITECTURE.md
Normal file
642
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,642 @@
|
||||
# Arquitectura de MangaReader
|
||||
|
||||
Este documento describe la arquitectura general del proyecto MangaReader, explicando cómo funcionan los componentes Backend y iOS App, y cómo fluyen los datos desde el scraping hasta el display en pantalla.
|
||||
|
||||
## Tabla de Contenidos
|
||||
|
||||
- [Visión General](#visión-general)
|
||||
- [Arquitectura del Sistema](#arquitectura-del-sistema)
|
||||
- [Arquitectura de la App iOS](#arquitectura-de-la-app-ios)
|
||||
- [Flujo de Datos](#flujo-de-datos)
|
||||
- [Patrones de Diseño](#patrones-de-diseño)
|
||||
- [Diagramas de Secuencia](#diagramas-de-secuencia)
|
||||
|
||||
## Visión General
|
||||
|
||||
MangaReader es una aplicación nativa de iOS para leer manga sin publicidad. El proyecto consta de dos componentes opcionales:
|
||||
|
||||
1. **Backend (Opcional)**: Servidor Node.js con Express que realiza scraping usando Puppeteer
|
||||
2. **iOS App**: Aplicación nativa SwiftUI que puede hacer scraping localmente usando WKWebView
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ MangaReader │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ Backend │ │ iOS App │ │
|
||||
│ │ (Opcional) │ │ (Principal) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ • Node.js │ │ • SwiftUI │ │
|
||||
│ │ • Express │ │ • WKWebView │ │
|
||||
│ │ • Puppeteer │ │ • Core Data │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │
|
||||
│ ▲ │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
│ Scraping Independiente │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Arquitectura del Sistema
|
||||
|
||||
### Componentes Principales
|
||||
|
||||
#### 1. Backend (Opcional)
|
||||
|
||||
El backend es una API REST opcional que puede actuar como intermediario:
|
||||
|
||||
```
|
||||
backend/
|
||||
├── scraper.js # Scraper con Puppeteer
|
||||
├── server.js # API REST con Express
|
||||
└── package.json
|
||||
```
|
||||
|
||||
**Responsabilidades:**
|
||||
- Realizar scraping de manhwaweb.com
|
||||
- Servir datos vía API REST
|
||||
- Cachear respuestas para mejorar rendimiento
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /api/health` - Health check
|
||||
- `GET /api/manga/:slug` - Información de un manga
|
||||
- `GET /api/manga/:slug/chapters` - Lista de capítulos
|
||||
- `GET /api/chapter/:slug/images` - Imágenes de un capítulo
|
||||
|
||||
**Nota Importante**: El backend es completamente opcional. La app iOS está diseñada para funcionar de manera autónoma sin necesidad del backend.
|
||||
|
||||
#### 2. iOS App (Principal)
|
||||
|
||||
La aplicación iOS es el componente principal y puede operar independientemente:
|
||||
|
||||
```
|
||||
ios-app/
|
||||
├── MangaReaderApp.swift # Entry point
|
||||
├── Info.plist
|
||||
└── Sources/
|
||||
├── Models/ # Modelos de datos
|
||||
│ └── Manga.swift
|
||||
├── Services/ # Lógica de negocio
|
||||
│ ├── ManhwaWebScraper.swift
|
||||
│ └── StorageService.swift
|
||||
└── Views/ # UI SwiftUI
|
||||
├── ContentView.swift
|
||||
├── MangaDetailView.swift
|
||||
└── ReaderView.swift
|
||||
```
|
||||
|
||||
## Arquitectura de la App iOS
|
||||
|
||||
### MVVM Pattern (Model-View-ViewModel)
|
||||
|
||||
La app iOS sigue el patrón MVVM para separar la UI de la lógica de negocio:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ MVVM Architecture │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────────────┐ ┌─────────┐ │
|
||||
│ │ View │◄────────│ ViewModel │◄────────│ Model │ │
|
||||
│ │(SwiftUI)│ │ (Observable) │ │(Struct) │ │
|
||||
│ └─────────┘ └─────────────────┘ └─────────┘ │
|
||||
│ ▲ │ │ │
|
||||
│ │ │ │ │
|
||||
│ └───────────────────────┴───────────────────────────┘ │
|
||||
│ Data Binding & Commands │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Estructura de Componentes
|
||||
|
||||
#### 1. Models (Datos)
|
||||
|
||||
**Ubicación**: `ios-app/Sources/Models/Manga.swift`
|
||||
|
||||
Los modelos son estructuras inmutables que representan los datos:
|
||||
|
||||
```swift
|
||||
- Manga: Información del manga (título, descripción, géneros, estado)
|
||||
- Chapter: Capítulo individual (número, título, URL, estado de lectura)
|
||||
- MangaPage: Página individual del capítulo (URL de imagen, índice)
|
||||
- ReadingProgress: Progreso de lectura del usuario
|
||||
- DownloadedChapter: Capítulo descargado localmente
|
||||
```
|
||||
|
||||
**Características:**
|
||||
- Inmutables (`struct`)
|
||||
- Conformes a `Codable` para serialización
|
||||
- Conformes a `Identifiable` para SwiftUI
|
||||
- Conformes a `Hashable` para comparaciones
|
||||
|
||||
#### 2. Services (Lógica de Negocio)
|
||||
|
||||
**Ubicación**: `ios-app/Sources/Services/`
|
||||
|
||||
##### ManhwaWebScraper.swift
|
||||
|
||||
Responsable del scraping de contenido web:
|
||||
|
||||
```swift
|
||||
class ManhwaWebScraper: NSObject, ObservableObject {
|
||||
// Singleton instance
|
||||
static let shared = ManhwaWebScraper()
|
||||
|
||||
// Funciones principales:
|
||||
func scrapeMangaInfo(mangaSlug: String) async throws -> Manga
|
||||
func scrapeChapters(mangaSlug: String) async throws -> [Chapter]
|
||||
func scrapeChapterImages(chapterSlug: String) async throws -> [String]
|
||||
}
|
||||
```
|
||||
|
||||
**Características:**
|
||||
- Usa `WKWebView` para ejecutar JavaScript
|
||||
- Implementa `async/await` para operaciones asíncronas
|
||||
- Patrón Singleton para compartir instancia
|
||||
- Manejo de errores con `ScrapingError`
|
||||
|
||||
##### StorageService.swift
|
||||
|
||||
Responsable del almacenamiento local:
|
||||
|
||||
```swift
|
||||
class StorageService {
|
||||
// Singleton instance
|
||||
static let shared = StorageService()
|
||||
|
||||
// Gestión de favoritos:
|
||||
func getFavorites() -> [String]
|
||||
func saveFavorite(mangaSlug: String)
|
||||
func removeFavorite(mangaSlug: String)
|
||||
|
||||
// Gestión de progreso:
|
||||
func saveReadingProgress(_ progress: ReadingProgress)
|
||||
func getReadingProgress(mangaSlug: String, chapterNumber: Int) -> ReadingProgress?
|
||||
|
||||
// Gestión de descargas:
|
||||
func saveDownloadedChapter(_ chapter: DownloadedChapter)
|
||||
func getDownloadedChapters() -> [DownloadedChapter]
|
||||
|
||||
// Gestión de imágenes:
|
||||
func saveImage(_ image: UIImage, ...) async throws -> URL
|
||||
func loadImage(...) -> UIImage?
|
||||
}
|
||||
```
|
||||
|
||||
**Características:**
|
||||
- Almacena favoritos en `UserDefaults`
|
||||
- Almacena progreso en `UserDefaults`
|
||||
- Guarda imágenes en el sistema de archivos
|
||||
- Usa `FileManager` para gestión de archivos
|
||||
|
||||
#### 3. ViewModels (Presentación)
|
||||
|
||||
**Ubicación**: Integrados en los archivos de Views
|
||||
|
||||
Los ViewModels coordinan entre Services y Views:
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
class MangaListViewModel: ObservableObject {
|
||||
@Published var mangas: [Manga] = []
|
||||
@Published var isLoading = false
|
||||
|
||||
private let scraper = ManhwaWebScraper.shared
|
||||
private let storage = StorageService.shared
|
||||
|
||||
func loadMangas() async
|
||||
func addManga(_ slug: String) async
|
||||
}
|
||||
```
|
||||
|
||||
**Responsabilidades:**
|
||||
- Mantener estado de la UI
|
||||
- Transformar datos para presentación
|
||||
- Manejar lógica de navegación
|
||||
- Coordinar llamadas a servicios
|
||||
|
||||
#### 4. Views (UI)
|
||||
|
||||
**Ubicación**: `ios-app/Sources/Views/`
|
||||
|
||||
##### ContentView.swift
|
||||
- Vista principal de la app
|
||||
- Lista de mangas con filtros
|
||||
- Búsqueda y añadir manga
|
||||
|
||||
##### MangaDetailView.swift
|
||||
- Detalle de un manga específico
|
||||
- Lista de capítulos
|
||||
- Descarga de capítulos
|
||||
|
||||
##### ReaderView.swift
|
||||
- Lector de imágenes
|
||||
- Gestos de zoom y pan
|
||||
- Configuración de lectura
|
||||
|
||||
## Flujo de Datos
|
||||
|
||||
### 1. Flujo de Scraping de Manga
|
||||
|
||||
```
|
||||
Usuario ingresa slug
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ ContentView -> MangaListViewModel.addManga(slug) │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ ManhwaWebScraper.scrapeMangaInfo(mangaSlug) │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ WKWebView carga URL de manhwaweb.com │
|
||||
│ https://manhwaweb.com/manga/{slug} │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ JavaScript ejecutado en WKWebView: │
|
||||
│ - Extrae título, descripción, géneros │
|
||||
│ - Extrae estado, imagen de portada │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ Datos parseados a struct Manga │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ ViewModel actualiza @Published var mangas │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ SwiftUI detecta cambio y re-renderiza UI │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2. Flujo de Lectura de Capítulo
|
||||
|
||||
```
|
||||
Usuario selecciona capítulo
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ MangaDetailView -> ReaderView(manga, chapter) │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ ReaderViewModel.loadPages() │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
├──► ¿Capítulo descargado?
|
||||
│ │
|
||||
│ SÍ │ NO
|
||||
│ ▼
|
||||
│ ┌─────────────────────────────────────────────────┐
|
||||
│ │ StorageService.getDownloadedChapter() │
|
||||
│ │ Cargar páginas locales │
|
||||
│ └─────────────────────────────────────────────────┘
|
||||
│ │
|
||||
│ └──────────────────┐
|
||||
│ │
|
||||
└─────────────────────────────┼──────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ ManhwaWebScraper.scrapeChapterImages(chapterSlug) │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ WKWebView carga URL del capítulo │
|
||||
│ https://manhwaweb.com/leer/{slug} │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ JavaScript extrae URLs de imágenes: │
|
||||
│ - Selecciona todas las etiquetas <img> │
|
||||
│ - Filtra elementos de UI (avatars, icons) │
|
||||
│ - Elimina duplicados │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ Array de strings con URLs de imágenes │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ Convertir a [MangaPage] y mostrar en ReaderView │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ ReaderView muestra imágenes con AsyncImage │
|
||||
│ Cache automático de imágenes en visualización │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3. Flujo de Guardado de Progreso
|
||||
|
||||
```
|
||||
Usuario navega a página X
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ ReaderViewModel.currentPage cambia a X │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ ReaderViewModel.saveProgress() │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ Crear ReadingProgress(mangaSlug, chapterNumber, pageNumber) │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ StorageService.saveReadingProgress(progress) │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ JSONEncoder codifica a Data │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ UserDefaults.set(data, forKey: "readingProgress") │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Patrones de Diseño
|
||||
|
||||
### 1. Singleton Pattern
|
||||
|
||||
**Uso**: Services compartidos
|
||||
|
||||
```swift
|
||||
class StorageService {
|
||||
static let shared = StorageService()
|
||||
private init() { ... }
|
||||
}
|
||||
|
||||
class ManhwaWebScraper {
|
||||
static let shared = ManhwaWebScraper()
|
||||
private init() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Beneficios:**
|
||||
- Unica instancia compartida en toda la app
|
||||
- Reduce consumo de memoria
|
||||
- Facilita acceso desde cualquier View/ViewModel
|
||||
|
||||
### 2. MVVM (Model-View-ViewModel)
|
||||
|
||||
**Uso**: Arquitectura general de la app
|
||||
|
||||
**Separación de responsabilidades:**
|
||||
- **Model**: Datos puras (`struct`, `Codable`)
|
||||
- **View**: UI pura (`SwiftUI`, reactive)
|
||||
- **ViewModel**: Lógica de presentación (`ObservableObject`)
|
||||
|
||||
**Beneficios:**
|
||||
- Testabilidad de ViewModels sin UI
|
||||
- Reutilización de ViewModels
|
||||
- Separación clara de concerns
|
||||
|
||||
### 3. Repository Pattern
|
||||
|
||||
**Uso**: Abstracción de fuentes de datos
|
||||
|
||||
```swift
|
||||
class StorageService {
|
||||
// Abstrae UserDefaults, FileManager, etc.
|
||||
func getFavorites() -> [String]
|
||||
func saveFavorite(mangaSlug: String)
|
||||
}
|
||||
```
|
||||
|
||||
**Beneficios:**
|
||||
- Interfaz unificada para diferentes storage
|
||||
- Fácil cambiar implementación
|
||||
- Centraliza lógica de persistencia
|
||||
|
||||
### 4. Async/Await Pattern
|
||||
|
||||
**Uso**: Operaciones de scraping
|
||||
|
||||
```swift
|
||||
func scrapeMangaInfo(mangaSlug: String) async throws -> Manga {
|
||||
// Operación asíncrona
|
||||
try await loadURLAndWait(url)
|
||||
let info = try await webView.evaluateJavaScript(...)
|
||||
return Manga(...)
|
||||
}
|
||||
```
|
||||
|
||||
**Beneficios:**
|
||||
- Código asíncrono legible
|
||||
- Manejo de errores claro
|
||||
- No bloquea el hilo principal
|
||||
|
||||
### 5. Observable Object Pattern
|
||||
|
||||
**Uso**: Reactividad en SwiftUI
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
class MangaListViewModel: ObservableObject {
|
||||
@Published var mangas: [Manga] = []
|
||||
|
||||
func loadMangas() async {
|
||||
mangas = ... // SwiftUI detecta cambio
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Beneficios:**
|
||||
- UI se actualiza automáticamente
|
||||
- Código declarativo
|
||||
- Menos código boilerplate
|
||||
|
||||
### 6. Factory Pattern (Implícito)
|
||||
|
||||
**Uso**: Creación de modelos
|
||||
|
||||
```swift
|
||||
// Funciones estáticas que crean instancias
|
||||
Chapter(number: 1, title: "...", url: "...", slug: "...")
|
||||
MangaPage(url: "...", index: 0)
|
||||
```
|
||||
|
||||
**Beneficios:**
|
||||
- Creación consistente de objetos
|
||||
- Validación en inicialización
|
||||
- Fácil de mantener
|
||||
|
||||
## Diagramas de Secuencia
|
||||
|
||||
### Secuencia 1: Agregar Manga
|
||||
|
||||
```
|
||||
┌─────────┐ ┌──────────────┐ ┌─────────────────┐ ┌────────────┐
|
||||
│ Usuario │ │ ViewModel │ │ Scraper │ │ WKWebView │
|
||||
└────┬────┘ └──────┬───────┘ └────────┬────────┘ └─────┬──────┘
|
||||
│ │ │ │
|
||||
│ ingresa slug │ │ │
|
||||
│───────────────>│ │ │
|
||||
│ │ │ │
|
||||
│ │ scrapeMangaInfo() │ │
|
||||
│ │────────────────────>│ │
|
||||
│ │ │ │
|
||||
│ │ │ load(URL) │
|
||||
│ │ │───────────────────>│
|
||||
│ │ │ │
|
||||
│ │ │ wait 3 seconds │
|
||||
│ │ │<───────────────────┤
|
||||
│ │ │ │
|
||||
│ │ │ evaluateJavaScript │
|
||||
│ │ │───────────────────>│
|
||||
│ │ │ (extrae datos) │
|
||||
│ │ │<───────────────────┤
|
||||
│ │ │ │
|
||||
│ │ Manga │ │
|
||||
│ │<────────────────────│ │
|
||||
│ │ │ │
|
||||
│ actualiza │ │ │
|
||||
│ UI │ │ │
|
||||
│<───────────────│ │ │
|
||||
│ │ │ │
|
||||
┌────┴────┐ ┌──────┴───────┘ └────────┴────────┘ └─────┴──────┘
|
||||
```
|
||||
|
||||
### Secuencia 2: Leer Capítulo
|
||||
|
||||
```
|
||||
┌─────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────┐
|
||||
│ Usuario │ │ ReaderView │ │ ViewModel │ │ Storage │
|
||||
└────┬────┘ └──────┬───────┘ └────────┬────────┘ └────┬─────┘
|
||||
│ │ │ │
|
||||
│ tap capítulo │ │ │
|
||||
│───────────────>│ │ │
|
||||
│ │ │ │
|
||||
│ │ loadPages() │ │
|
||||
│ │────────────────────>│ │
|
||||
│ │ │ │
|
||||
│ │ │ isDownloaded()? │
|
||||
│ │ │──────────────────>│
|
||||
│ │ │ │
|
||||
│ │ │ NO │
|
||||
│ │ │<──────────────────┤
|
||||
│ │ │ │
|
||||
│ │ │ scrapeChapterImages│
|
||||
│ │ │ (via Scraper) │
|
||||
│ │ │ │
|
||||
│ │ [MangaPage] │ │
|
||||
│ │<────────────────────│ │
|
||||
│ │ │ │
|
||||
│ muestra páginas│ │ │
|
||||
│<───────────────│ │ │
|
||||
│ │ │ │
|
||||
┌────┴────┐ ┌──────┴───────┘ └────────┴────────┘ └────┴─────┘
|
||||
```
|
||||
|
||||
### Secuencia 3: Guardar Favorito
|
||||
|
||||
```
|
||||
┌─────────┐ ┌──────────────┐ ┌─────────────────┐
|
||||
│ Usuario │ │ View │ │ StorageService │
|
||||
└────┬────┘ └──────┬───────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
│ tap corazón │ │
|
||||
│───────────────>│ │
|
||||
│ │ │
|
||||
│ │ toggleFavorite() │
|
||||
│ │────────────────────>│
|
||||
│ │ │
|
||||
│ │ │ getFavorites()
|
||||
│ │ │ (UserDefaults)
|
||||
│ │ │
|
||||
│ │ │ saveFavorite()
|
||||
│ │ │ (UserDefaults)
|
||||
│ │ │
|
||||
│ │ actualiza UI │
|
||||
│ │<────────────────────│
|
||||
│ │ │
|
||||
┌────┴────┘ ┌──────┴───────┘ └────────┴────────┘
|
||||
```
|
||||
|
||||
## Decisiones de Arquitectura
|
||||
|
||||
### ¿Por qué WKWebView para scraping?
|
||||
|
||||
1. **JavaScript Rendering**: manhwaweb.com usa JavaScript para cargar contenido
|
||||
2. **Sin dependencias externas**: No requiere librerías de terceros
|
||||
3. **Aislamiento**: El scraping ocurre en contexto separado
|
||||
4. **Control**: Full control sobre timeouts, cookies, headers
|
||||
|
||||
### ¿Por qué UserDefaults para favoritos/progreso?
|
||||
|
||||
1. **Simplicidad**: Datos pequeños y simples
|
||||
2. **Sincronización**: iCloud sync automático disponible
|
||||
3. **Rendimiento**: Lectura/escritura rápida
|
||||
4. **Persistencia**: Survive app reinstalls (si iCloud)
|
||||
|
||||
### ¿Por qué FileManager para imágenes?
|
||||
|
||||
1. **Tamaño**: Imágenes pueden ser grandes (MBs)
|
||||
2. **Performance**: Acceso directo a archivos
|
||||
3. **Cache control**: Control manual de qué guardar
|
||||
4. **Escalabilidad**: No limitado por UserDefaults
|
||||
|
||||
### ¿Por qué MVVM?
|
||||
|
||||
1. **SwiftUI nativo**: SwiftUI está diseñado para MVVM
|
||||
2. **Testabilidad**: ViewModels testeables sin UI
|
||||
3. **Reactibilidad**: `@Published` y `ObservableObject`
|
||||
4. **Separación**: UI separada de lógica de negocio
|
||||
|
||||
## Consideraciones de Escalabilidad
|
||||
|
||||
### Futuras Mejoras
|
||||
|
||||
1. **Database**: Migrar de UserDefaults a Core Data o SQLite
|
||||
2. **Background Tasks**: Descargas en background
|
||||
3. **Caching Strategy**: LRU cache para imágenes
|
||||
4. **Pagination**: Cargar capítulos bajo demanda
|
||||
5. **Sync Service**: Sincronización entre dispositivos
|
||||
|
||||
### Rendimiento
|
||||
|
||||
- **Lazy Loading**: Cargar imágenes bajo demanda
|
||||
- **Image Compression**: JPEG 80% calidad
|
||||
- **Request Batching**: Descargar páginas en paralelo
|
||||
- **Memory Management**: Liberar imágenes no visibles
|
||||
|
||||
## Seguridad
|
||||
|
||||
### Consideraciones
|
||||
|
||||
1. **No se almacenan credenciales**: La app no requiere login
|
||||
2. **SSL Pinning**: Considerar para producción
|
||||
3. **Input Validation**: Validar slugs antes de scraping
|
||||
4. **Rate Limiting**: No sobrecargar el servidor objetivo
|
||||
|
||||
---
|
||||
|
||||
**Última actualización**: Febrero 2026
|
||||
**Versión**: 1.0.0
|
||||
735
docs/CONTRIBUTING.md
Normal file
735
docs/CONTRIBUTING.md
Normal file
@@ -0,0 +1,735 @@
|
||||
# Contributing to MangaReader
|
||||
|
||||
Gracias por tu interés en contribuir a MangaReader. Este documento proporciona una guía completa para contribuyentes, incluyendo cómo agregar nuevas fuentes de manga, modificar el scraper, estándares de código y testing.
|
||||
|
||||
## Tabla de Contenidos
|
||||
|
||||
- [Configuración del Entorno de Desarrollo](#configuración-del-entorno-de-desarrollo)
|
||||
- [Cómo Agregar Nuevas Fuentes de Manga](#cómo-agregar-nuevas-fuentes-de-manga)
|
||||
- [Cómo Modificar el Scraper](#cómo-modificar-el-scraper)
|
||||
- [Estándares de Código](#estándares-de-código)
|
||||
- [Testing](#testing)
|
||||
- [Pull Request Guidelines](#pull-request-guidelines)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Configuración del Entorno de Desarrollo
|
||||
|
||||
### Requisitos Previos
|
||||
|
||||
1. **MacOS** con Xcode 15+
|
||||
2. **iOS 15+** device o simulator
|
||||
3. **Git** para control de versiones
|
||||
4. **Cuenta de Developer** de Apple (opcional, para firmar)
|
||||
|
||||
### Pasos Iniciales
|
||||
|
||||
1. **Fork el repositorio** (si es proyecto open source)
|
||||
2. **Clona tu fork**:
|
||||
```bash
|
||||
git clone https://github.com/tu-usuario/MangaReader.git
|
||||
cd MangaReader/ios-app
|
||||
```
|
||||
|
||||
3. **Abre el proyecto en Xcode**:
|
||||
```bash
|
||||
open MangaReader.xcodeproj
|
||||
```
|
||||
|
||||
4. **Configura el signing**:
|
||||
- Selecciona el proyecto en el sidebar
|
||||
- En "Signing & Capabilities", elige tu Team
|
||||
- Asegúrate que "Automatically manage signing" esté activado
|
||||
|
||||
5. **Ejecuta el proyecto**:
|
||||
- Selecciona un dispositivo o simulador
|
||||
- Presiona `Cmd + R`
|
||||
|
||||
---
|
||||
|
||||
## Cómo Agregar Nuevas Fuentes de Manga
|
||||
|
||||
MangaReader está diseñado para soportar múltiples fuentes de manga. Actualmente soporta manhwaweb.com, pero puedes agregar más fuentes siguiendo estos pasos.
|
||||
|
||||
### Arquitectura de Fuentes
|
||||
|
||||
Las fuentes se implementan usando un protocolo común:
|
||||
|
||||
```swift
|
||||
protocol MangaSource {
|
||||
var name: String { get }
|
||||
var baseURL: String { get }
|
||||
|
||||
func fetchMangaInfo(slug: String) async throws -> Manga
|
||||
func fetchChapters(mangaSlug: String) async throws -> [Chapter]
|
||||
func fetchChapterImages(chapterSlug: String) async throws -> [String]
|
||||
}
|
||||
```
|
||||
|
||||
### Paso 1: Crear el Scraper de la Nueva Fuente
|
||||
|
||||
Crea un nuevo archivo en `ios-app/Sources/Services/`:
|
||||
|
||||
**Ejemplo: `MangaTownScraper.swift`**
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
/// Scraper para mangatown.com
|
||||
/// Implementa el protocolo MangaSource para agregar soporte de esta fuente
|
||||
@MainActor
|
||||
class MangaTownScraper: NSObject, ObservableObject, MangaSource {
|
||||
|
||||
// MARK: - MangaSource Protocol
|
||||
|
||||
let name = "MangaTown"
|
||||
let baseURL = "https://www.mangatown.com"
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
static let shared = MangaTownScraper()
|
||||
private var webView: WKWebView?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
setupWebView()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupWebView() {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.applicationNameForUserAgent = "Mozilla/5.0"
|
||||
|
||||
webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
webView?.navigationDelegate = self
|
||||
}
|
||||
|
||||
// MARK: - MangaSource Implementation
|
||||
|
||||
/// Obtiene la información de un manga desde MangaTown
|
||||
/// - Parameter slug: Identificador único del manga
|
||||
/// - Returns: Objeto Manga con información completa
|
||||
/// - Throws: ScrapingError si falla el scraping
|
||||
func fetchMangaInfo(slug: String) async throws -> Manga {
|
||||
guard let webView = webView else {
|
||||
throw ScrapingError.webViewNotInitialized
|
||||
}
|
||||
|
||||
let url = URL(string: "\(baseURL)/manga/\(slug)")!
|
||||
try await loadURLAndWait(url, webView: webView)
|
||||
|
||||
// Extraer información usando JavaScript
|
||||
let info: [String: Any] = try await webView.evaluateJavaScript("""
|
||||
(function() {
|
||||
// Implementación específica para MangaTown
|
||||
return {
|
||||
title: document.querySelector('.manga-title')?.textContent || '',
|
||||
description: document.querySelector('.manga-summary')?.textContent || '',
|
||||
// ... más extracciones
|
||||
};
|
||||
})();
|
||||
""") as! [String: Any]
|
||||
|
||||
return Manga(
|
||||
slug: slug,
|
||||
title: info["title"] as? String ?? "",
|
||||
description: info["description"] as? String ?? "",
|
||||
genres: [],
|
||||
status: "",
|
||||
url: url.absoluteString,
|
||||
coverImage: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Obtiene los capítulos de un manga
|
||||
/// - Parameter mangaSlug: Slug del manga
|
||||
/// - Returns: Array de capítulos ordenados
|
||||
/// - Throws: ScrapingError si falla
|
||||
func fetchChapters(mangaSlug: String) async throws -> [Chapter] {
|
||||
// Implementación similar a scrapeChapters
|
||||
// ...
|
||||
return []
|
||||
}
|
||||
|
||||
/// Obtiene las imágenes de un capítulo
|
||||
/// - Parameter chapterSlug: Slug del capítulo
|
||||
/// - Returns: Array de URLs de imágenes
|
||||
/// - Throws: ScrapingError si falla
|
||||
func fetchChapterImages(chapterSlug: String) async throws -> [String] {
|
||||
// Implementación similar a scrapeChapterImages
|
||||
// ...
|
||||
return []
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func loadURLAndWait(_ url: URL, webView: WKWebView, waitTime: Double = 3.0) async throws {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
webView.load(URLRequest(url: url))
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + waitTime) {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WKNavigationDelegate
|
||||
|
||||
extension MangaTownScraper: WKNavigationDelegate {
|
||||
nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
// Navegación completada
|
||||
}
|
||||
|
||||
nonisolated func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
print("Navigation failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Paso 2: Registrar la Nueva Fuente
|
||||
|
||||
Agrega la fuente al registro de fuentes disponibles. Crea un archivo `MangaSourceRegistry.swift`:
|
||||
|
||||
```swift
|
||||
/// Registro de fuentes de manga disponibles
|
||||
enum MangaSourceRegistry {
|
||||
static let allSources: [MangaSource] = [
|
||||
ManhwaWebScraper.shared,
|
||||
MangaTownScraper.shared,
|
||||
]
|
||||
|
||||
static func source(named name: String) -> MangaSource? {
|
||||
allSources.first { $0.name == name }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Paso 3: Actualizar la UI para Seleccionar Fuente
|
||||
|
||||
Modifica `ContentView.swift` para permitir selección de fuente:
|
||||
|
||||
```swift
|
||||
// En MangaListViewModel
|
||||
@Published var selectedSource: MangaSource = ManhwaWebScraper.shared
|
||||
|
||||
// En ContentView
|
||||
Picker("Fuente", selection: $viewModel.selectedSource) {
|
||||
ForEach(MangaSourceRegistry.allSources, id: \.name) { source in
|
||||
Text(source.name).tag(source)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Paso 4: Testing de la Nueva Fuente
|
||||
|
||||
Crea tests para verificar que el scraper funciona correctamente:
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
@testable import MangaReader
|
||||
|
||||
class MangaTownScraperTests: XCTestCase {
|
||||
var scraper: MangaTownScraper!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
scraper = MangaTownScraper.shared
|
||||
}
|
||||
|
||||
func testFetchMangaInfo() async throws {
|
||||
let manga = try await scraper.fetchMangaInfo(slug: "one-piece")
|
||||
|
||||
XCTAssertFalse(manga.title.isEmpty)
|
||||
XCTAssertFalse(manga.description.isEmpty)
|
||||
}
|
||||
|
||||
func testFetchChapters() async throws {
|
||||
let chapters = try await scraper.fetchChapters(mangaSlug: "one-piece")
|
||||
|
||||
XCTAssertFalse(chapters.isEmpty)
|
||||
XCTAssertTrue(chapters.first?.number == 1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cómo Modificar el Scraper
|
||||
|
||||
Si la estructura de manhwaweb.com cambia y el scraper deja de funcionar, sigue estos pasos para actualizarlo.
|
||||
|
||||
### Paso 1: Investigar la Nueva Estructura
|
||||
|
||||
1. Abre manhwaweb.com en Safari/Chrome
|
||||
2. Abre Web Inspector (F12 o Cmd+Option+I)
|
||||
3. Navega a un manga/capítulo
|
||||
4. Inspecciona el HTML para encontrar los nuevos selectores
|
||||
|
||||
### Paso 2: Identificar Selectores Clave
|
||||
|
||||
Busca los siguientes elementos:
|
||||
|
||||
**Para Manga Info:**
|
||||
- Selector del título (ej: `h1`, `.title`, `[class*="title"]`)
|
||||
- Selector de la descripción (ej: `p`, `.description`)
|
||||
- Selector de géneros (ej: `a[href*="/genero/"]`, `.genres a`)
|
||||
- Selector de estado (ej: regex en body o `.status`)
|
||||
- Selector de cover (ej: `.cover img`, `[class*="cover"] img`)
|
||||
|
||||
**Para Capítulos:**
|
||||
- Selector de links (ej: `a[href*="/leer/"]`, `.chapter-link`)
|
||||
- Cómo extraer el número de capítulo (regex o atributo)
|
||||
|
||||
**Para Imágenes:**
|
||||
- Selector de imágenes (ej: `img`, `.page-image img`)
|
||||
- Cómo distinguir imágenes de contenido de UI
|
||||
|
||||
### Paso 3: Actualizar el JavaScript
|
||||
|
||||
En `ManhwaWebScraper.swift`, actualiza el JavaScript en los métodos correspondientes:
|
||||
|
||||
**Ejemplo: Actualizar `scrapeMangaInfo`**
|
||||
|
||||
```swift
|
||||
let mangaInfo: [String: Any] = try await webView.evaluateJavaScript("""
|
||||
(function() {
|
||||
// ACTUALIZAR: Nuevo selector de título
|
||||
let title = '';
|
||||
const titleEl = document.querySelector('.nuevo-selector-titulo');
|
||||
if (titleEl) {
|
||||
title = titleEl.textContent?.trim() || '';
|
||||
}
|
||||
|
||||
// ACTUALIZAR: Nueva lógica de descripción
|
||||
let description = '';
|
||||
const descEl = document.querySelector('.nuevo-selector-desc');
|
||||
if (descEl) {
|
||||
description = descEl.textContent?.trim() || '';
|
||||
}
|
||||
|
||||
// Resto de extracciones...
|
||||
return {
|
||||
title: title,
|
||||
description: description,
|
||||
// ...
|
||||
};
|
||||
})();
|
||||
""") as! [String: Any]
|
||||
```
|
||||
|
||||
### Paso 4: Probar los Cambios
|
||||
|
||||
1. Compila y ejecuta la app
|
||||
2. Intenta agregar un manga existente
|
||||
3. Verifica que la información se muestre correctamente
|
||||
4. Intenta leer un capítulo
|
||||
5. Verifica que las imágenes carguen
|
||||
|
||||
### Paso 5: Manejo de Errores
|
||||
|
||||
Agrega manejo de errores robusto:
|
||||
|
||||
```swift
|
||||
// Agregar fallbacks
|
||||
let titleEl = document.querySelector('.nuevo-selector') ||
|
||||
document.querySelector('.selector-respalgo') ||
|
||||
document.querySelector('h1'); // Último recurso
|
||||
|
||||
if (titleEl) {
|
||||
title = titleEl.textContent?.trim() || '';
|
||||
}
|
||||
```
|
||||
|
||||
### Consejos para Troubleshooting
|
||||
|
||||
1. **Incrementa el tiempo de espera** si la página carga lento:
|
||||
```swift
|
||||
try await loadURLAndWait(url, waitForImages: true)
|
||||
// Aumenta el tiempo en loadURLAndWait
|
||||
```
|
||||
|
||||
2. **Verifica que JavaScript esté habilitado** en WKWebView
|
||||
|
||||
3. **Revisa la consola** del WebView agregando logging:
|
||||
```swift
|
||||
webView.evaluateJavaScript("console.log('Debug info')")
|
||||
```
|
||||
|
||||
4. **Usa `try?`** en vez de `try!` temporalmente para evitar crashes:
|
||||
```swift
|
||||
let info = try? webView.evaluateJavaScript(...) as? [String: Any]
|
||||
print("Info: \(info ?? [:])")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Estándares de Código
|
||||
|
||||
### Swift Style Guide
|
||||
|
||||
Sigue las convenciones de Swift para código limpio y mantenible.
|
||||
|
||||
#### 1. Nomenclatura
|
||||
|
||||
**Clases y Structs**: PascalCase
|
||||
```swift
|
||||
class ManhwaWebScraper { }
|
||||
struct Manga { }
|
||||
```
|
||||
|
||||
**Propiedades y Métodos**: camelCase
|
||||
```swift
|
||||
var mangaTitle: String
|
||||
func scrapeMangaInfo() { }
|
||||
```
|
||||
|
||||
**Constantes Privadas**: camelCase con prefijo si es necesario
|
||||
```swift
|
||||
private let favoritesKey = "favoriteMangas"
|
||||
```
|
||||
|
||||
#### 2. Organización de Código
|
||||
|
||||
Usa MARK comments para organizar:
|
||||
|
||||
```swift
|
||||
class ManhwaWebScraper {
|
||||
// MARK: - Properties
|
||||
|
||||
private var webView: WKWebView?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() { }
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
func scrapeMangaInfo() { }
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func loadURLAndWait() { }
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Documentación
|
||||
|
||||
Documenta todas las funciones públicas:
|
||||
|
||||
```swift
|
||||
/// Obtiene la lista de capítulos de un manga
|
||||
///
|
||||
/// Este método carga la página del manga en un WKWebView,
|
||||
/// ejecuta JavaScript para extraer los capítulos, y retorna
|
||||
/// un array ordenado de manera descendente.
|
||||
///
|
||||
/// - Parameter mangaSlug: El slug único del manga
|
||||
/// - Returns: Array de capítulos ordenados por número (descendente)
|
||||
/// - Throws: `ScrapingError` si el WebView no está inicializado
|
||||
/// o si falla la extracción de contenido
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// do {
|
||||
/// let chapters = try await scraper.scrapeChapters(mangaSlug: "one-piece")
|
||||
/// print("Found \(chapters.count) chapters")
|
||||
/// } catch {
|
||||
/// print("Error: \(error)")
|
||||
/// }
|
||||
/// ```
|
||||
func scrapeChapters(mangaSlug: String) async throws -> [Chapter] {
|
||||
// Implementación
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Async/Await
|
||||
|
||||
Usa async/await para código asíncrono:
|
||||
|
||||
```swift
|
||||
// BUENO
|
||||
func loadManga() async throws -> Manga {
|
||||
let manga = try await scraper.scrapeMangaInfo(slug: slug)
|
||||
return manga
|
||||
}
|
||||
|
||||
// MAL (evitar completion handlers si es posible)
|
||||
func loadManga(completion: @escaping (Result<Manga, Error>) -> Void) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Error Handling
|
||||
|
||||
Usa errores tipados en vez de genéricos:
|
||||
|
||||
```swift
|
||||
// BUENO
|
||||
enum ScrapingError: LocalizedError {
|
||||
case webViewNotInitialized
|
||||
case pageLoadFailed
|
||||
}
|
||||
|
||||
// MAL
|
||||
func scrape() throws {
|
||||
throw NSError(domain: "Error", code: 1, userInfo: nil)
|
||||
}
|
||||
```
|
||||
|
||||
#### 6. Opcionalidad
|
||||
|
||||
Usa opcionales con cuidado:
|
||||
|
||||
```swift
|
||||
// BUENO - unwrap seguro
|
||||
if let coverImage = manga.coverImage {
|
||||
// Usar coverImage
|
||||
}
|
||||
|
||||
// BUENO - nil coalescing
|
||||
let title = manga.title ?? "Unknown"
|
||||
|
||||
// MAL - force unwrap (evitar a menos que estés 100% seguro)
|
||||
let image = UIImage(contentsOfFile: path)!
|
||||
```
|
||||
|
||||
#### 7. Closures
|
||||
|
||||
Usa trailing closure syntax cuando sea el último parámetro:
|
||||
|
||||
```swift
|
||||
// BUENO
|
||||
DispatchQueue.main.async {
|
||||
print("Async code")
|
||||
}
|
||||
|
||||
// ACEPTABLE
|
||||
DispatchQueue.main.async(execute: {
|
||||
print("Async code")
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Escribir Tests
|
||||
|
||||
Crea tests para nuevas funcionalidades:
|
||||
|
||||
**Ejemplo de Unit Test:**
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
@testable import MangaReader
|
||||
|
||||
class StorageServiceTests: XCTestCase {
|
||||
var storage: StorageService!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
storage = StorageService.shared
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Limpiar después de cada test
|
||||
storage.clearAllDownloads()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testSaveAndRetrieveFavorite() {
|
||||
// Given
|
||||
let slug = "test-manga"
|
||||
|
||||
// When
|
||||
storage.saveFavorite(mangaSlug: slug)
|
||||
let isFavorite = storage.isFavorite(mangaSlug: slug)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(isFavorite)
|
||||
}
|
||||
|
||||
func testRemoveFavorite() {
|
||||
// Given
|
||||
let slug = "test-manga"
|
||||
storage.saveFavorite(mangaSlug: slug)
|
||||
|
||||
// When
|
||||
storage.removeFavorite(mangaSlug: slug)
|
||||
let isFavorite = storage.isFavorite(mangaSlug: slug)
|
||||
|
||||
// Then
|
||||
XCTAssertFalse(isFavorite)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ejecutar Tests
|
||||
|
||||
1. En Xcode, presiona `Cmd + U` para ejecutar todos los tests
|
||||
2. Para ejecutar un test específico, click en el diamante junto al nombre del test
|
||||
3. Los tests deben ejecutarse en el simulator o en un dispositivo real
|
||||
|
||||
### Cobertura de Código
|
||||
|
||||
Apunta a tener al menos 70% de cobertura de código:
|
||||
|
||||
- Servicios: 80%+
|
||||
- ViewModels: 70%+
|
||||
- Models: 90%+ (son datos simples)
|
||||
- Views: 50%+ (UI testing es más difícil)
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
### Antes de Abrir un PR
|
||||
|
||||
1. **Actualiza tu rama**:
|
||||
```bash
|
||||
git checkout main
|
||||
git pull upstream main
|
||||
git checkout tu-rama
|
||||
git rebase main
|
||||
```
|
||||
|
||||
2. **Resuelve conflicts** si los hay
|
||||
|
||||
3. **Ejecuta los tests**:
|
||||
```bash
|
||||
xcodebuild test -scheme MangaReader -destination 'platform=iOS Simulator,name=iPhone 15'
|
||||
```
|
||||
|
||||
4. **Limpia el código**:
|
||||
- Remueve `print()` statements de debugging
|
||||
- Formatea el código (Xcode puede hacerlo automáticamente: Ctrl + I)
|
||||
- Remueve comentarios TODO/FIXME implementados
|
||||
|
||||
### Estructura del PR
|
||||
|
||||
Usa esta plantilla para tu PR:
|
||||
|
||||
```markdown
|
||||
## Descripción
|
||||
Breve descripción de los cambios.
|
||||
|
||||
## Tipo de Cambio
|
||||
- [ ] Bug fix (non-breaking change que arregla un issue)
|
||||
- [ ] New feature (non-breaking change que agrega funcionalidad)
|
||||
- [ ] Breaking change (fix or feature que causaría breaking changes)
|
||||
- [ ] Documentation update
|
||||
|
||||
## Testing
|
||||
Describe los tests que ejecutaste:
|
||||
- [ ] Unit tests pasan
|
||||
- [ ] Manual testing en device/simulator
|
||||
- [ ] Probado en iOS 15, iOS 16, iOS 17
|
||||
|
||||
## Screenshots (si aplica)
|
||||
Before/After screenshots para cambios de UI.
|
||||
|
||||
## Checklist
|
||||
- [ ] Mi código sigue los style guidelines
|
||||
- [ ] He realizado self-review de mi código
|
||||
- [ ] He comentado código complejo
|
||||
- [ ] He actualizado la documentación
|
||||
- [ ] No hay nuevos warnings
|
||||
- [ ] He agregado tests que prueban mis cambios
|
||||
- [ ] Todos los tests pasan
|
||||
```
|
||||
|
||||
### Review Process
|
||||
|
||||
1. **Automated Checks**: CI ejecutará tests automáticamente
|
||||
2. **Code Review**: Al menos 1 revisor debe aprobar
|
||||
3. **Testing**: El revisor probará los cambios en un dispositivo
|
||||
4. **Merge**: El maintainer mergeará si todo está bien
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issues Comunes
|
||||
|
||||
#### 1. WebView no carga contenido
|
||||
|
||||
**Síntoma**: `scrapeMangaInfo` retorna datos vacíos
|
||||
|
||||
**Solución**:
|
||||
- Aumenta el tiempo de espera en `loadURLAndWait`
|
||||
- Verifica que la URL sea correcta
|
||||
- Agrega logging para ver qué JavaScript retorna
|
||||
|
||||
#### 2. Tests fallan en CI pero pasan localmente
|
||||
|
||||
**Síntoma**: Tests pasan en tu máquina pero fallan en GitHub Actions
|
||||
|
||||
**Solución**:
|
||||
- Asegúrate de que los tests no dependen de datos locales
|
||||
- Usa mocks en vez de scrapers reales en tests
|
||||
- Verifica que la configuración de iOS sea la misma
|
||||
|
||||
#### 3. Imágenes no cargan
|
||||
|
||||
**Síntoma**: ReaderView muestra placeholders en vez de imágenes
|
||||
|
||||
**Solución**:
|
||||
- Verifica que las URLs sean válidas
|
||||
- Agrega logging en `scrapeChapterImages`
|
||||
- Prueba las URLs en un navegador
|
||||
- Verifica que no haya bloqueo de red
|
||||
|
||||
#### 4. El proyecto no compila
|
||||
|
||||
**Síntoma**: Errores de "Cannot find type" o "No such module"
|
||||
|
||||
**Solución**:
|
||||
1. Limpia el proyecto: `Cmd + Shift + K`
|
||||
2. Cierra Xcode
|
||||
3. Borra `DerivedData`: `rm -rf ~/Library/Developer/Xcode/DerivedData`
|
||||
4. Abre Xcode y rebuild
|
||||
|
||||
### Pedir Ayuda
|
||||
|
||||
Si estás atascado:
|
||||
|
||||
1. **Busca en Issues existentes**: Puede que alguien ya tuvo el mismo problema
|
||||
2. **Crea un Issue** con:
|
||||
- Descripción detallada del problema
|
||||
- Pasos para reproducir
|
||||
- Logs relevantes
|
||||
- Tu entorno (Xcode version, iOS version, macOS version)
|
||||
3. **Únete a Discord/Slack** (si existe) para ayuda en tiempo real
|
||||
|
||||
---
|
||||
|
||||
## Recursos Adicionales
|
||||
|
||||
### Documentación de Referencia
|
||||
|
||||
- [Swift Language Guide](https://docs.swift.org/swift-book/)
|
||||
- [SwiftUI Documentation](https://developer.apple.com/documentation/swiftui)
|
||||
- [WKWebView Reference](https://developer.apple.com/documentation/webkit/wkwebview)
|
||||
- [Swift Concurrency](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html)
|
||||
|
||||
### Herramientas Útiles
|
||||
|
||||
- **SwiftLint**: Linter para Swift
|
||||
- **SwiftFormat**: Formateador de código
|
||||
- **SwiftGen**: Generador de código para recursos
|
||||
|
||||
### Comunidad
|
||||
|
||||
- [Swift Forums](https://forums.swift.org/)
|
||||
- [Stack Overflow - swift tag](https://stackoverflow.com/questions/tagged/swift)
|
||||
- [r/swift subreddit](https://www.reddit.com/r/swift/)
|
||||
|
||||
---
|
||||
|
||||
**Última actualización**: Febrero 2026
|
||||
**Versión**: 1.0.0
|
||||
277
docs/README.md
Normal file
277
docs/README.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# MangaReader - Documentación Técnica
|
||||
|
||||
Bienvenido a la documentación técnica de MangaReader. Este directorio contiene toda la información necesaria para entender, desarrollar y contribuir al proyecto.
|
||||
|
||||
## Documentos Disponibles
|
||||
|
||||
### [ARCHITECTURE.md](./ARCHITECTURE.md)
|
||||
Documentación completa de la arquitectura del sistema.
|
||||
|
||||
**Contenido:**
|
||||
- Visión general del sistema (Backend + iOS App)
|
||||
- Arquitectura MVVM de la app iOS
|
||||
- Diagramas de flujo de datos (scraping, lectura, guardado)
|
||||
- Patrones de diseño implementados (Singleton, MVVM, Repository, etc.)
|
||||
- Diagramas de secuencia en ASCII
|
||||
- Decisiones arquitectónicas y su justificación
|
||||
|
||||
**Para quién es:**
|
||||
- Desarrolladores que necesitan entender la estructura general
|
||||
- Arquitectos que evalúan el diseño del sistema
|
||||
- Nuevos contribuyentes que necesitan orientación
|
||||
|
||||
---
|
||||
|
||||
### [API.md](./API.md)
|
||||
Documentación de la API y modelos de datos.
|
||||
|
||||
**Contenido:**
|
||||
- Modelos de datos (Manga, Chapter, MangaPage, ReadingProgress, etc.)
|
||||
- Documentación completa de servicios (ManhwaWebScraper, StorageService)
|
||||
- Descripción de ViewModels y sus responsabilidades
|
||||
- Errores y manejo de excepciones
|
||||
- Ejemplos de uso para cada método público
|
||||
|
||||
**Para quién es:**
|
||||
- Desarrolladores que integran funcionalidades
|
||||
- Equipo QA que necesita entender comportamientos esperados
|
||||
- Contribuyentes que agregan nuevas features
|
||||
|
||||
---
|
||||
|
||||
### [CONTRIBUTING.md](./CONTRIBUTING.md)
|
||||
Guía para contribuir al proyecto.
|
||||
|
||||
**Contenido:**
|
||||
- Configuración del entorno de desarrollo
|
||||
- Cómo agregar nuevas fuentes de manga
|
||||
- Cómo modificar el scraper existente
|
||||
- Estándares de código (Swift style guide)
|
||||
- Testing (unit tests, integración)
|
||||
- Pull request guidelines
|
||||
- Troubleshooting común
|
||||
|
||||
**Para quién es:**
|
||||
- Nuevos contribuyentes
|
||||
- Desarrolladores que agregan features
|
||||
- Maintainers que revisan PRs
|
||||
|
||||
---
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
MangaReader/
|
||||
├── docs/ # Documentación técnica
|
||||
│ ├── README.md # Este archivo (índice)
|
||||
│ ├── ARCHITECTURE.md # Arquitectura y diagramas
|
||||
│ ├── API.md # API y modelos de datos
|
||||
│ └── CONTRIBUTING.md # Guía para contribuyentes
|
||||
│
|
||||
├── ios-app/ # Aplicación iOS
|
||||
│ ├── MangaReaderApp.swift # Entry point de la app
|
||||
│ ├── Sources/
|
||||
│ │ ├── Models/ # Modelos de datos
|
||||
│ │ │ └── Manga.swift # Models documentados
|
||||
│ │ ├── Services/ # Lógica de negocio
|
||||
│ │ │ ├── ManhwaWebScraper.swift # Scraper documentado
|
||||
│ │ │ └── StorageService.swift # Storage documentado
|
||||
│ │ └── Views/ # UI SwiftUI
|
||||
│ │ ├── ContentView.swift
|
||||
│ │ ├── MangaDetailView.swift
|
||||
│ │ └── ReaderView.swift
|
||||
│ └── MangaReader.xcodeproj
|
||||
│
|
||||
└── backend/ # Backend opcional (Node.js)
|
||||
├── scraper.js
|
||||
├── server.js
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resumen Rápido de Componentes
|
||||
|
||||
### Modelos de Datos
|
||||
- **Manga**: Información completa de un manga
|
||||
- **Chapter**: Capítulo individual con estado de lectura
|
||||
- **MangaPage**: Página individual (imagen)
|
||||
- **ReadingProgress**: Progreso de lectura del usuario
|
||||
- **DownloadedChapter**: Capítulo descargado localmente
|
||||
|
||||
### Servicios
|
||||
- **ManhwaWebScraper**: Scraper usando WKWebView para manhwaweb.com
|
||||
- **StorageService**: Gestión de almacenamiento local (UserDefaults + FileManager)
|
||||
|
||||
### ViewModels
|
||||
- **MangaListViewModel**: Lista principal de mangas
|
||||
- **MangaDetailViewModel**: Detalle de un manga
|
||||
- **ReaderViewModel**: Lector de capítulos
|
||||
|
||||
### Views
|
||||
- **ContentView**: Vista principal con lista de mangas
|
||||
- **MangaDetailView**: Detalle y capítulos de un manga
|
||||
- **ReaderView**: Lector de imágenes con zoom/pan
|
||||
|
||||
---
|
||||
|
||||
## Comenzar Rápidamente
|
||||
|
||||
### Para Entender la Arquitectura
|
||||
1. Lee [ARCHITECTURE.md](./ARCHITECTURE.md) - Sección "Visión General"
|
||||
2. Revisa los diagramas de flujo de datos
|
||||
3. Estudia los patrones de diseño usados
|
||||
|
||||
### Para Usar la API
|
||||
1. Consulta [API.md](./API.md) - Sección "Modelos de Datos"
|
||||
2. Revisa los servicios disponibles
|
||||
3. Mira los ejemplos de uso
|
||||
|
||||
### Para Contribuir
|
||||
1. Lee [CONTRIBUTING.md](./CONTRIBUTING.md) - "Configuración del Entorno"
|
||||
2. Configura tu entorno de desarrollo
|
||||
3. Revisa los estándares de código
|
||||
4. Sigue el workflow de Pull Requests
|
||||
|
||||
---
|
||||
|
||||
## Tecnologías Utilizadas
|
||||
|
||||
### iOS App
|
||||
- **SwiftUI**: Framework de UI declarativo
|
||||
- **Combine**: Programación reactiva
|
||||
- **WKWebView**: Rendering de JavaScript
|
||||
- **UserDefaults**: Almacenamiento de preferencias
|
||||
- **FileManager**: Almacenamiento de archivos
|
||||
|
||||
### Backend (Opcional)
|
||||
- **Node.js**: Runtime de JavaScript
|
||||
- **Express**: Framework web
|
||||
- **Puppeteer**: Headless Chrome automation
|
||||
|
||||
---
|
||||
|
||||
## Patrones de Diseño Principales
|
||||
|
||||
| Patrón | Implementación | Propósito |
|
||||
|--------|---------------|-----------|
|
||||
| **MVVM** | ViewModels separados de Views | Separar UI de lógica |
|
||||
| **Singleton** | `StorageService.shared`, `ManhwaWebScraper.shared` | Instancia única compartida |
|
||||
| **Repository** | `StorageService` abstrae UserDefaults/FileManager | Interfaz unificada de datos |
|
||||
| **Async/Await** | Métodos `async throws` en scraper | Código asíncrono legible |
|
||||
| **Observable** | `@Published`, `ObservableObject` | Reactividad en SwiftUI |
|
||||
|
||||
---
|
||||
|
||||
## Flujos Principales
|
||||
|
||||
### 1. Agregar Manga
|
||||
```
|
||||
Usuario → ContentView → MangaListViewModel
|
||||
↓
|
||||
ManhwaWebScraper.scrapeMangaInfo()
|
||||
↓
|
||||
WKWebView + JavaScript
|
||||
↓
|
||||
Manga actualizado en UI
|
||||
```
|
||||
|
||||
### 2. Leer Capítulo
|
||||
```
|
||||
Usuario → MangaDetailView → ReaderView
|
||||
↓
|
||||
ReaderViewModel.loadPages()
|
||||
↓
|
||||
¿Descargado? ─NO→ ManhwaWebScraper.scrapeChapterImages()
|
||||
│S ↓
|
||||
└─→ StorageService.getDownloadedChapter()
|
||||
↓
|
||||
Mostrar páginas en ReaderView
|
||||
```
|
||||
|
||||
### 3. Guardar Progreso
|
||||
```
|
||||
Usuario navega a página X
|
||||
↓
|
||||
ReaderViewModel.currentPage = X
|
||||
↓
|
||||
ReaderViewModel.saveProgress()
|
||||
↓
|
||||
StorageService.saveReadingProgress()
|
||||
↓
|
||||
UserDefaults (JSON codificado)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Preguntas Frecuentes
|
||||
|
||||
**¿Puedo agregar nuevas fuentes de manga?**
|
||||
Sí. Lee [CONTRIBUTING.md](./CONTRIBUTING.md) - Sección "Cómo Agregar Nuevas Fuentes de Manga".
|
||||
|
||||
**¿El backend es obligatorio?**
|
||||
No. La app iOS funciona completamente de manera autónoma. El backend es opcional y puede servir como cache/API intermedia.
|
||||
|
||||
**¿Cómo cambio el scraper si manhwaweb.com cambia su estructura?**
|
||||
Consulta [CONTRIBUTING.md](./CONTRIBUTING.md) - Sección "Cómo Modificar el Scraper".
|
||||
|
||||
**¿Cómo ejecuto los tests?**
|
||||
Ve a [CONTRIBUTING.md](./CONTRIBUTING.md) - Sección "Testing".
|
||||
|
||||
---
|
||||
|
||||
## Convenciones de Documentación
|
||||
|
||||
### En Código Swift
|
||||
- **///**: Comentarios de documentación públicos (soportan Markdown)
|
||||
- **//:**: Comentarios de sección (MARK)
|
||||
- **//**: Comentarios de implementación
|
||||
|
||||
### Ejemplo de Documentación de Método
|
||||
```swift
|
||||
/// Obtiene la lista de capítulos de un manga.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// - Parameter mangaSlug: Slug único del manga
|
||||
/// - Returns: Array de `Chapter` ordenados por número (descendente)
|
||||
/// - Throws: `ScrapingError` si el WebView no está inicializado
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// let chapters = try await scraper.scrapeChapters(mangaSlug: "one-piece")
|
||||
/// ```
|
||||
func scrapeChapters(mangaSlug: String) async throws -> [Chapter]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recursos Adicionales
|
||||
|
||||
### Documentación Oficial
|
||||
- [Swift Language Guide](https://docs.swift.org/swift-book/)
|
||||
- [SwiftUI Documentation](https://developer.apple.com/documentation/swiftui)
|
||||
- [WKWebView Reference](https://developer.apple.com/documentation/webkit/wkwebview)
|
||||
|
||||
### Herramientas
|
||||
- **SwiftLint**: Linter para código Swift
|
||||
- **SwiftFormat**: Formateador automático
|
||||
- **Jazzy**: Generador de documentación (Objective-C/Swift)
|
||||
|
||||
---
|
||||
|
||||
## Soporte y Contribución
|
||||
|
||||
¿Encontraste un error en la documentación? ¿Falta algo?
|
||||
|
||||
1. Abre un issue en el repositorio
|
||||
2. O envía un Pull Request con las mejoras
|
||||
|
||||
Para más detalles sobre cómo contribuir, revisa [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
||||
---
|
||||
|
||||
**Última actualización**: Febrero 2026
|
||||
**Versión**: 1.0.0
|
||||
**Mantenedor**: MangaReader Team
|
||||
Reference in New Issue
Block a user