JJURTER commited on
Commit
358bb63
·
1 Parent(s): b277044

Add manga URL colorizer tab with cache preview and batch colorization

Browse files
Files changed (5) hide show
  1. .gitignore +9 -0
  2. README.md +7 -1
  3. app.py +263 -3
  4. manga_colorizer.py +360 -0
  5. requirements.txt +3 -1
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ cache/
2
+ outputs/
3
+ downloads/
4
+ manga_cache/
5
+ *.zip
6
+
7
+ __pycache__/
8
+ *.py[cod]
9
+ .pytest_cache/
README.md CHANGED
@@ -11,4 +11,10 @@ license: apache-2.0
11
  short_description: FireRed-Image-Edit × Qwen-Image-Edit-Rapid (Transformers)
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
11
  short_description: FireRed-Image-Edit × Qwen-Image-Edit-Rapid (Transformers)
12
  ---
13
 
14
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
15
+
16
+ ## Manga URL Colorizer
17
+
18
+ The Space now includes a second tab for manga page extraction and batch colorization from allowed URLs. It caches original pages under `cache/manga/<slug>/original/`, saves generated pages under `cache/manga/<slug>/colorized/`, and produces a downloadable `colorized_manga.zip`.
19
+
20
+ Use only content you own or have permission to process. The URL fetcher is limited to configured allowed domains and does not bypass login, captcha, paywall, Cloudflare, or other access protections.
app.py CHANGED
@@ -11,6 +11,17 @@ import html as html_lib
11
  from io import BytesIO
12
  from PIL import Image
13
 
 
 
 
 
 
 
 
 
 
 
 
14
  MAX_SEED = np.iinfo(np.int32).max
15
  LANCZOS = getattr(Image, "Resampling", Image).LANCZOS
16
 
@@ -215,6 +226,126 @@ def infer(images_b64_json, prompt, seed, randomize_seed, guidance_scale, steps,
215
  torch.cuda.empty_cache()
216
 
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  css = r"""
219
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
220
  *{box-sizing:border-box;margin:0;padding:0}
@@ -225,6 +356,20 @@ body,.gradio-container{
225
  .dark body,.dark .gradio-container{background:#0f0f13!important;color:#e4e4e7!important}
226
  footer{display:none!important}
227
  .hidden-input{display:none!important;height:0!important;overflow:hidden!important;margin:0!important;padding:0!important}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  #example-load-btn{
229
  position:absolute!important;left:-9999px!important;top:-9999px!important;
230
  width:1px!important;height:1px!important;opacity:0.01!important;
@@ -988,7 +1133,9 @@ with gr.Blocks() as demo:
988
  example_result = gr.Textbox(value="", elem_id="example-result-data", elem_classes="hidden-input", container=False)
989
  example_load_btn = gr.Button("Load Example", elem_id="example-load-btn")
990
 
991
- gr.HTML(f"""
 
 
992
  <div class="app-shell">
993
  <div class="app-header">
994
  <div class="app-header-left">
@@ -1133,6 +1280,96 @@ with gr.Blocks() as demo:
1133
  </div>
1134
  """)
1135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1136
  run_btn = gr.Button("Run", elem_id="gradio-run-btn")
1137
 
1138
  demo.load(fn=None, js=gallery_js)
@@ -1159,11 +1396,34 @@ with gr.Blocks() as demo:
1159
  queue=False,
1160
  )
1161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1162
  if __name__ == "__main__":
1163
  demo.queue(max_size=50).launch(
1164
  css=css,
1165
  mcp_server=True,
1166
  ssr_mode=False,
1167
  show_error=True,
1168
- allowed_paths=["examples"],
1169
- )
 
11
  from io import BytesIO
12
  from PIL import Image
13
 
14
+ from manga_colorizer import (
15
+ MANGA_CACHE_ROOT,
16
+ MANGA_DEFAULT_PROMPT,
17
+ MANGA_NEGATIVE_PROMPT,
18
+ MangaColorizerError,
19
+ extract_and_cache_manga,
20
+ get_colorized_path,
21
+ load_manga_metadata,
22
+ make_colorized_zip,
23
+ )
24
+
25
  MAX_SEED = np.iinfo(np.int32).max
26
  LANCZOS = getattr(Image, "Resampling", Image).LANCZOS
27
 
 
226
  torch.cuda.empty_cache()
227
 
228
 
229
+ def extract_manga_for_ui(url, max_pages):
230
+ try:
231
+ status, original_paths, metadata_json = extract_and_cache_manga(url, max_pages)
232
+ choices = [f"{idx:03d}" for idx in range(1, len(original_paths) + 1)]
233
+ default_selection = choices[:1]
234
+ return (
235
+ status,
236
+ original_paths,
237
+ metadata_json,
238
+ gr.update(choices=choices, value=default_selection),
239
+ )
240
+ except MangaColorizerError as exc:
241
+ raise gr.Error(str(exc)) from exc
242
+
243
+
244
+ def _parse_selected_page_indexes(selected_pages, total_pages):
245
+ if isinstance(selected_pages, str):
246
+ selected_pages = [selected_pages]
247
+ indexes = []
248
+ for page in selected_pages or []:
249
+ try:
250
+ page_number = int(str(page).split()[0])
251
+ except (TypeError, ValueError):
252
+ continue
253
+ index = page_number - 1
254
+ if 0 <= index < total_pages and index not in indexes:
255
+ indexes.append(index)
256
+ return indexes
257
+
258
+
259
+ def colorize_manga_page(image_path: str, prompt: str, seed: int, guidance_scale: float, steps: int) -> Image.Image:
260
+ input_image = Image.open(image_path).convert("RGB")
261
+ width, height = update_dimensions_on_upload(input_image)
262
+ generator = torch.Generator(device=device).manual_seed(int(seed))
263
+ result_image = pipe(
264
+ image=[input_image],
265
+ prompt=prompt,
266
+ negative_prompt=MANGA_NEGATIVE_PROMPT,
267
+ height=height,
268
+ width=width,
269
+ num_inference_steps=int(steps),
270
+ generator=generator,
271
+ true_cfg_scale=float(guidance_scale),
272
+ ).images[0]
273
+ output_path = get_colorized_path(image_path)
274
+ result_image.save(output_path)
275
+ return result_image
276
+
277
+
278
+ @spaces.GPU
279
+ def colorize_cached_manga(
280
+ metadata_json,
281
+ prompt,
282
+ selected_pages,
283
+ colorize_all,
284
+ seed,
285
+ randomize_seed,
286
+ guidance_scale,
287
+ steps,
288
+ max_color_pages,
289
+ progress=gr.Progress(track_tqdm=True),
290
+ ):
291
+ if not torch.cuda.is_available():
292
+ raise gr.Error("No hay GPU disponible para colorear manga.")
293
+
294
+ try:
295
+ metadata = load_manga_metadata(metadata_json)
296
+ except MangaColorizerError as exc:
297
+ raise gr.Error(str(exc)) from exc
298
+
299
+ original_paths = metadata.get("original_paths") or []
300
+ existing_paths = [path for path in original_paths if path and os.path.exists(path)]
301
+ if not existing_paths:
302
+ raise gr.Error("No hay imagenes en cache. Extrae las paginas primero.")
303
+
304
+ try:
305
+ limit = int(max_color_pages)
306
+ except (TypeError, ValueError):
307
+ limit = 5
308
+ limit = max(1, min(limit, 30))
309
+
310
+ if colorize_all:
311
+ page_indexes = list(range(min(len(existing_paths), limit)))
312
+ else:
313
+ page_indexes = _parse_selected_page_indexes(selected_pages, len(existing_paths))[:limit]
314
+
315
+ if not page_indexes:
316
+ raise gr.Error("Selecciona al menos una pagina o activa colorear todas.")
317
+
318
+ prompt = (prompt or MANGA_DEFAULT_PROMPT).strip() or MANGA_DEFAULT_PROMPT
319
+ try:
320
+ base_seed = int(seed)
321
+ except (TypeError, ValueError):
322
+ base_seed = 0
323
+
324
+ colorized_paths = []
325
+ total = len(page_indexes)
326
+ for progress_index, page_index in enumerate(page_indexes, start=1):
327
+ page_seed = random.randint(0, MAX_SEED) if randomize_seed else base_seed
328
+ page_path = existing_paths[page_index]
329
+ progress(progress_index / total, desc=f"Colorizing page {page_index + 1:03d}")
330
+ try:
331
+ colorize_manga_page(page_path, prompt, page_seed, guidance_scale, steps)
332
+ finally:
333
+ gc.collect()
334
+ torch.cuda.empty_cache()
335
+ colorized_paths.append(str(get_colorized_path(page_path)))
336
+
337
+ try:
338
+ zip_path = make_colorized_zip(colorized_paths)
339
+ except MangaColorizerError as exc:
340
+ raise gr.Error(str(exc)) from exc
341
+
342
+ status = (
343
+ f"Listo: {len(colorized_paths)} pagina(s) coloreada(s). "
344
+ f"ZIP generado. Solo usar con contenido propio o con permiso."
345
+ )
346
+ return status, colorized_paths, zip_path
347
+
348
+
349
  css = r"""
350
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
351
  *{box-sizing:border-box;margin:0;padding:0}
 
356
  .dark body,.dark .gradio-container{background:#0f0f13!important;color:#e4e4e7!important}
357
  footer{display:none!important}
358
  .hidden-input{display:none!important;height:0!important;overflow:hidden!important;margin:0!important;padding:0!important}
359
+ .manga-colorizer-shell{
360
+ max-width:1200px;margin:12px auto 24px;padding:18px;background:#18181b;
361
+ border:1px solid #27272a;border-radius:12px;box-shadow:0 18px 36px rgba(0,0,0,.34);
362
+ }
363
+ .manga-colorizer-shell h2{color:#ffffff!important;font-size:22px!important;margin-bottom:6px!important}
364
+ .manga-colorizer-shell p,.manga-colorizer-shell label,.manga-colorizer-shell span{
365
+ color:#d4d4d8!important;
366
+ }
367
+ .manga-colorizer-shell textarea,.manga-colorizer-shell input{
368
+ background:#09090b!important;color:#f4f4f5!important;border-color:#3f3f46!important;
369
+ }
370
+ .manga-colorizer-shell button{
371
+ border-radius:8px!important;font-weight:700!important;
372
+ }
373
  #example-load-btn{
374
  position:absolute!important;left:-9999px!important;top:-9999px!important;
375
  width:1px!important;height:1px!important;opacity:0.01!important;
 
1133
  example_result = gr.Textbox(value="", elem_id="example-result-data", elem_classes="hidden-input", container=False)
1134
  example_load_btn = gr.Button("Load Example", elem_id="example-load-btn")
1135
 
1136
+ with gr.Tabs(elem_id="main-tabs"):
1137
+ with gr.Tab("Image Editor"):
1138
+ gr.HTML(f"""
1139
  <div class="app-shell">
1140
  <div class="app-header">
1141
  <div class="app-header-left">
 
1280
  </div>
1281
  """)
1282
 
1283
+ with gr.Tab("Manga URL Colorizer"):
1284
+ with gr.Column(elem_classes="manga-colorizer-shell"):
1285
+ gr.Markdown(
1286
+ "## Manga URL Colorizer\n"
1287
+ "Extrae paginas desde una URL permitida, guarda originales en cache, "
1288
+ "previsualiza y colorea por lotes usando el mismo modelo cargado. "
1289
+ "**Solo usar con contenido propio o con permiso.**"
1290
+ )
1291
+ with gr.Row():
1292
+ manga_url = gr.Textbox(
1293
+ label="Manga URL",
1294
+ placeholder="https://hmanga.asia/porque-eres-tu-enano/#all",
1295
+ scale=4,
1296
+ )
1297
+ manga_max_pages = gr.Slider(
1298
+ label="Maximo de paginas a descargar",
1299
+ minimum=1,
1300
+ maximum=120,
1301
+ step=1,
1302
+ value=10,
1303
+ scale=1,
1304
+ )
1305
+ manga_extract_btn = gr.Button("1. Extraer imagenes", variant="primary")
1306
+ manga_status = gr.Markdown("Ready.")
1307
+ manga_original_gallery = gr.Gallery(
1308
+ label="Preview de paginas originales",
1309
+ columns=5,
1310
+ height=420,
1311
+ object_fit="contain",
1312
+ )
1313
+ manga_metadata = gr.Textbox(label="Manga metadata", visible=False)
1314
+
1315
+ with gr.Row():
1316
+ manga_selected_pages = gr.CheckboxGroup(
1317
+ label="Paginas a colorear",
1318
+ choices=[],
1319
+ value=[],
1320
+ scale=3,
1321
+ )
1322
+ manga_colorize_all = gr.Checkbox(
1323
+ label="Colorear todas las paginas descargadas",
1324
+ value=False,
1325
+ scale=1,
1326
+ )
1327
+
1328
+ manga_prompt = gr.Textbox(
1329
+ label="Prompt de colorizacion",
1330
+ value=MANGA_DEFAULT_PROMPT,
1331
+ lines=6,
1332
+ )
1333
+ with gr.Row():
1334
+ manga_seed = gr.Slider(
1335
+ label="Seed",
1336
+ minimum=0,
1337
+ maximum=MAX_SEED,
1338
+ step=1,
1339
+ value=0,
1340
+ )
1341
+ manga_randomize_seed = gr.Checkbox(label="Randomize seed", value=True)
1342
+ with gr.Row():
1343
+ manga_guidance_scale = gr.Slider(
1344
+ label="Guidance scale",
1345
+ minimum=1.0,
1346
+ maximum=10.0,
1347
+ step=0.1,
1348
+ value=1.0,
1349
+ )
1350
+ manga_steps = gr.Slider(
1351
+ label="Steps",
1352
+ minimum=1,
1353
+ maximum=50,
1354
+ step=1,
1355
+ value=4,
1356
+ )
1357
+ manga_max_color_pages = gr.Slider(
1358
+ label="Maximo de paginas a colorear",
1359
+ minimum=1,
1360
+ maximum=30,
1361
+ step=1,
1362
+ value=5,
1363
+ )
1364
+ manga_colorize_btn = gr.Button("2. Colorear manga", variant="primary")
1365
+ manga_colorized_gallery = gr.Gallery(
1366
+ label="Resultados coloreados",
1367
+ columns=5,
1368
+ height=420,
1369
+ object_fit="contain",
1370
+ )
1371
+ manga_zip = gr.File(label="Descargar ZIP coloreado")
1372
+
1373
  run_btn = gr.Button("Run", elem_id="gradio-run-btn")
1374
 
1375
  demo.load(fn=None, js=gallery_js)
 
1396
  queue=False,
1397
  )
1398
 
1399
+ manga_extract_btn.click(
1400
+ fn=extract_manga_for_ui,
1401
+ inputs=[manga_url, manga_max_pages],
1402
+ outputs=[manga_status, manga_original_gallery, manga_metadata, manga_selected_pages],
1403
+ queue=False,
1404
+ )
1405
+
1406
+ manga_colorize_btn.click(
1407
+ fn=colorize_cached_manga,
1408
+ inputs=[
1409
+ manga_metadata,
1410
+ manga_prompt,
1411
+ manga_selected_pages,
1412
+ manga_colorize_all,
1413
+ manga_seed,
1414
+ manga_randomize_seed,
1415
+ manga_guidance_scale,
1416
+ manga_steps,
1417
+ manga_max_color_pages,
1418
+ ],
1419
+ outputs=[manga_status, manga_colorized_gallery, manga_zip],
1420
+ )
1421
+
1422
  if __name__ == "__main__":
1423
  demo.queue(max_size=50).launch(
1424
  css=css,
1425
  mcp_server=True,
1426
  ssr_mode=False,
1427
  show_error=True,
1428
+ allowed_paths=["examples", str(MANGA_CACHE_ROOT)],
1429
+ )
manga_colorizer.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import ipaddress
3
+ import json
4
+ import re
5
+ import zipfile
6
+ from pathlib import Path
7
+ from urllib.parse import unquote, urljoin, urlparse, urlunparse
8
+
9
+ import requests
10
+ from bs4 import BeautifulSoup
11
+
12
+
13
+ ALLOWED_DOMAINS = ["hmanga.asia"]
14
+ MANGA_CACHE_ROOT = Path("cache") / "manga"
15
+ MANGA_IMAGE_EXTENSIONS = (".webp", ".jpg", ".jpeg", ".png")
16
+ MAX_MANGA_PAGES = 120
17
+ MAX_IMAGE_BYTES = 35 * 1024 * 1024
18
+ MAX_REDIRECTS = 5
19
+
20
+ MANGA_DEFAULT_PROMPT = (
21
+ "Colorize this black and white manga page into a clean modern anime/manhwa color style. "
22
+ "Preserve the original line art, panel layout, speech bubbles, text, character identity, "
23
+ "clothing, pose, anatomy, camera angle, background, and composition. Do not crop, redraw, "
24
+ "remove, or add panels. Keep all text readable and unchanged. Apply natural skin tones, "
25
+ "soft shadows, vibrant but controlled colors, clean cel shading, subtle highlights, detailed "
26
+ "hair coloring, and harmonious background colors. Maintain the original manga style and avoid "
27
+ "overpainting the ink lines. Do not sexualize the characters, do not add nudity, do not change "
28
+ "clothing coverage, and do not alter the body proportions."
29
+ )
30
+
31
+ MANGA_NEGATIVE_PROMPT = (
32
+ "worst quality, low quality, blurry, cropped, extra panels, missing panels, unreadable text, "
33
+ "changed text, watermark, signature, deformed anatomy, extra limbs, missing limbs, overpainted "
34
+ "lineart, messy colors, nudity, sexualized, changed outfit, changed pose"
35
+ )
36
+
37
+ REQUEST_HEADERS = {
38
+ "User-Agent": "Mozilla/5.0",
39
+ "Referer": "https://hmanga.asia/",
40
+ }
41
+
42
+
43
+ class MangaColorizerError(ValueError):
44
+ pass
45
+
46
+
47
+ def _normalize_host(hostname):
48
+ if not hostname:
49
+ return ""
50
+ return hostname.strip().strip("[]").lower().rstrip(".")
51
+
52
+
53
+ def _is_blocked_host(hostname):
54
+ host = _normalize_host(hostname)
55
+ if host in {"localhost", "local", "0", "0.0.0.0"}:
56
+ return True
57
+ try:
58
+ ip = ipaddress.ip_address(host)
59
+ except ValueError:
60
+ return False
61
+ return any(
62
+ [
63
+ ip.is_loopback,
64
+ ip.is_private,
65
+ ip.is_link_local,
66
+ ip.is_multicast,
67
+ ip.is_reserved,
68
+ ip.is_unspecified,
69
+ ]
70
+ )
71
+
72
+
73
+ def _is_allowed_domain(hostname):
74
+ host = _normalize_host(hostname)
75
+ return any(host == domain or host.endswith(f".{domain}") for domain in ALLOWED_DOMAINS)
76
+
77
+
78
+ def sanitize_url(url: str) -> str:
79
+ if not url or not str(url).strip():
80
+ raise MangaColorizerError("URL invalida: pega una URL http/https.")
81
+
82
+ parsed = urlparse(str(url).strip())
83
+ if parsed.scheme not in {"http", "https"}:
84
+ raise MangaColorizerError("URL invalida: solo se permite http o https.")
85
+ if parsed.username or parsed.password:
86
+ raise MangaColorizerError("URL invalida: no uses credenciales embebidas.")
87
+
88
+ host = _normalize_host(parsed.hostname)
89
+ if _is_blocked_host(host):
90
+ raise MangaColorizerError("URL bloqueada por seguridad.")
91
+ if not _is_allowed_domain(host):
92
+ allowed = ", ".join(ALLOWED_DOMAINS)
93
+ raise MangaColorizerError(f"Dominio no permitido. Dominios permitidos: {allowed}.")
94
+
95
+ if parsed.port and parsed.port not in {80, 443}:
96
+ raise MangaColorizerError("URL invalida: puerto no permitido.")
97
+
98
+ path = parsed.path or "/"
99
+ netloc = host
100
+ if parsed.port:
101
+ netloc = f"{netloc}:{parsed.port}"
102
+ return urlunparse((parsed.scheme, netloc, path, "", parsed.query, parsed.fragment))
103
+
104
+
105
+ def get_manga_slug(url: str) -> str:
106
+ clean_url = sanitize_url(url)
107
+ parsed = urlparse(clean_url)
108
+ parts = [part for part in unquote(parsed.path).split("/") if part]
109
+ base = parts[-1] if parts else "manga"
110
+ slug = re.sub(r"[^a-zA-Z0-9]+", "-", base).strip("-").lower() or "manga"
111
+ slug = slug[:80].strip("-") or "manga"
112
+ short_hash = hashlib.sha256(clean_url.encode("utf-8")).hexdigest()[:8]
113
+ return f"{slug}-{short_hash}"
114
+
115
+
116
+ def _looks_like_blocked_page(html: str) -> bool:
117
+ lowered = html.lower()
118
+ hard_markers = [
119
+ "cf-chl",
120
+ "cloudflare",
121
+ "turnstile",
122
+ "captcha",
123
+ "verify you are human",
124
+ "attention required",
125
+ "just a moment",
126
+ "please enable cookies",
127
+ ]
128
+ if any(marker in lowered for marker in hard_markers):
129
+ return True
130
+ has_login_form = "<form" in lowered and ("login" in lowered or "password" in lowered)
131
+ has_manga_images = "/wp-content/uploads/mangas/" in lowered
132
+ return has_login_form and not has_manga_images
133
+
134
+
135
+ def _get_with_validated_redirects(url, *, headers=None, stream=False, timeout=20):
136
+ current_url = sanitize_url(url)
137
+ request_headers = headers or REQUEST_HEADERS
138
+ for _ in range(MAX_REDIRECTS + 1):
139
+ response = requests.get(
140
+ current_url,
141
+ headers=request_headers,
142
+ timeout=timeout,
143
+ allow_redirects=False,
144
+ stream=stream,
145
+ )
146
+ if response.is_redirect or response.is_permanent_redirect:
147
+ location = response.headers.get("Location")
148
+ response.close()
149
+ if not location:
150
+ raise MangaColorizerError("Redireccion sin destino valido.")
151
+ current_url = sanitize_url(urljoin(current_url, location))
152
+ continue
153
+ return response, current_url
154
+ raise MangaColorizerError("Demasiadas redirecciones.")
155
+
156
+
157
+ def fetch_manga_html(url: str) -> str:
158
+ clean_url = sanitize_url(url)
159
+ try:
160
+ response, _ = _get_with_validated_redirects(clean_url, headers=REQUEST_HEADERS, timeout=20)
161
+ except requests.Timeout as exc:
162
+ raise MangaColorizerError("Timeout al descargar la pagina del manga.") from exc
163
+ except requests.RequestException as exc:
164
+ raise MangaColorizerError(f"Error de red al descargar la pagina: {exc}") from exc
165
+
166
+ try:
167
+ if response.status_code == 403:
168
+ raise MangaColorizerError("El sitio devolvio 403. Puede requerir login, captcha o bloqueo.")
169
+ if response.status_code == 404:
170
+ raise MangaColorizerError("El sitio devolvio 404. Revisa que la URL exista.")
171
+ if response.status_code >= 400:
172
+ raise MangaColorizerError(f"El sitio devolvio HTTP {response.status_code}.")
173
+
174
+ html = response.text or ""
175
+ if not html.strip():
176
+ raise MangaColorizerError("La pagina descargada esta vacia.")
177
+ if _looks_like_blocked_page(html):
178
+ raise MangaColorizerError("La pagina parece estar protegida por login, captcha o Cloudflare.")
179
+ return html
180
+ finally:
181
+ response.close()
182
+
183
+
184
+ def _candidate_image_values(img):
185
+ for attr in ("src", "data-src", "data-lazy-src", "data-original"):
186
+ value = img.get(attr)
187
+ if value:
188
+ yield value.strip()
189
+
190
+
191
+ def _natural_image_sort_key(url):
192
+ parsed = urlparse(url)
193
+ path = unquote(parsed.path)
194
+ dirname = path.rsplit("/", 1)[0]
195
+ filename = path.rsplit("/", 1)[-1]
196
+ stem = filename.rsplit(".", 1)[0]
197
+ numbers = [int(num) for num in re.findall(r"\d+", stem)]
198
+ return (dirname, 0 if numbers else 1, numbers, filename.lower())
199
+
200
+
201
+ def extract_manga_image_urls(html: str, base_url: str) -> list[str]:
202
+ if not html or not html.strip():
203
+ return []
204
+
205
+ clean_base_url = sanitize_url(base_url)
206
+ soup = BeautifulSoup(html, "html.parser")
207
+ candidates = []
208
+ for selector in ("img.img-fluid.no-lazy", "div[id] img", "img"):
209
+ candidates.extend(soup.select(selector))
210
+
211
+ seen = set()
212
+ image_urls = []
213
+ for img in candidates:
214
+ for raw_value in _candidate_image_values(img):
215
+ if not raw_value or raw_value.startswith("data:"):
216
+ continue
217
+ try:
218
+ absolute_url = sanitize_url(urljoin(clean_base_url, raw_value))
219
+ except MangaColorizerError:
220
+ continue
221
+ parsed = urlparse(absolute_url)
222
+ path_lower = parsed.path.lower()
223
+ if "/wp-content/uploads/mangas/" not in path_lower:
224
+ continue
225
+ if not path_lower.endswith(MANGA_IMAGE_EXTENSIONS):
226
+ continue
227
+ if absolute_url in seen:
228
+ continue
229
+ seen.add(absolute_url)
230
+ image_urls.append(absolute_url)
231
+
232
+ return sorted(image_urls, key=_natural_image_sort_key)
233
+
234
+
235
+ def download_manga_images(image_urls: list[str], cache_dir: Path, referer: str) -> list[str]:
236
+ cache_dir = Path(cache_dir)
237
+ cache_dir.mkdir(parents=True, exist_ok=True)
238
+ local_paths = []
239
+
240
+ for index, image_url in enumerate(image_urls, start=1):
241
+ clean_url = sanitize_url(image_url)
242
+ parsed = urlparse(clean_url)
243
+ ext = Path(parsed.path).suffix.lower()
244
+ if ext not in MANGA_IMAGE_EXTENSIONS:
245
+ ext = ".webp"
246
+ target_path = cache_dir / f"{index:03d}{ext}"
247
+ local_paths.append(str(target_path))
248
+
249
+ if target_path.exists() and target_path.stat().st_size > 0:
250
+ continue
251
+
252
+ headers = dict(REQUEST_HEADERS)
253
+ headers["Referer"] = sanitize_url(referer)
254
+ try:
255
+ response, _ = _get_with_validated_redirects(clean_url, headers=headers, stream=True, timeout=30)
256
+ except requests.Timeout as exc:
257
+ raise MangaColorizerError(f"Timeout al descargar imagen {index:03d}.") from exc
258
+ except requests.RequestException as exc:
259
+ raise MangaColorizerError(f"Error de red al descargar imagen {index:03d}: {exc}") from exc
260
+
261
+ try:
262
+ if response.status_code >= 400:
263
+ raise MangaColorizerError(f"HTTP {response.status_code} al descargar imagen {index:03d}.")
264
+ content_length = int(response.headers.get("Content-Length") or 0)
265
+ if content_length > MAX_IMAGE_BYTES:
266
+ raise MangaColorizerError(f"Imagen {index:03d} excede el limite de tamano.")
267
+
268
+ tmp_path = target_path.with_suffix(target_path.suffix + ".part")
269
+ total_bytes = 0
270
+ with tmp_path.open("wb") as handle:
271
+ for chunk in response.iter_content(chunk_size=1024 * 256):
272
+ if not chunk:
273
+ continue
274
+ total_bytes += len(chunk)
275
+ if total_bytes > MAX_IMAGE_BYTES:
276
+ tmp_path.unlink(missing_ok=True)
277
+ raise MangaColorizerError(f"Imagen {index:03d} excede el limite de tamano.")
278
+ handle.write(chunk)
279
+ tmp_path.replace(target_path)
280
+ finally:
281
+ response.close()
282
+
283
+ return local_paths
284
+
285
+
286
+ def extract_and_cache_manga(url: str, max_pages: int):
287
+ clean_url = sanitize_url(url)
288
+ try:
289
+ page_limit = int(max_pages)
290
+ except (TypeError, ValueError):
291
+ page_limit = 10
292
+ page_limit = max(1, min(page_limit, MAX_MANGA_PAGES))
293
+
294
+ html = fetch_manga_html(clean_url)
295
+ image_urls = extract_manga_image_urls(html, clean_url)
296
+ if not image_urls:
297
+ raise MangaColorizerError("No se encontraron imagenes de manga en la pagina.")
298
+
299
+ limited_urls = image_urls[:page_limit]
300
+ slug = get_manga_slug(clean_url)
301
+ manga_root = MANGA_CACHE_ROOT / slug
302
+ original_dir = manga_root / "original"
303
+ colorized_dir = manga_root / "colorized"
304
+ colorized_dir.mkdir(parents=True, exist_ok=True)
305
+ original_paths = download_manga_images(limited_urls, original_dir, clean_url)
306
+
307
+ metadata = {
308
+ "url": clean_url,
309
+ "slug": slug,
310
+ "original_dir": str(original_dir),
311
+ "colorized_dir": str(colorized_dir),
312
+ "image_urls": limited_urls,
313
+ "original_paths": original_paths,
314
+ }
315
+ status = (
316
+ f"Listo: {len(original_paths)} imagen(es) en cache. "
317
+ f"Detectadas en total: {len(image_urls)}. Solo usar con contenido propio o con permiso."
318
+ )
319
+ return status, original_paths, json.dumps(metadata, indent=2)
320
+
321
+
322
+ def load_manga_metadata(metadata_json: str):
323
+ if not metadata_json or not str(metadata_json).strip():
324
+ raise MangaColorizerError("Primero extrae imagenes para crear metadata.")
325
+ try:
326
+ metadata = json.loads(metadata_json)
327
+ except json.JSONDecodeError as exc:
328
+ raise MangaColorizerError("Metadata invalida. Vuelve a extraer las imagenes.") from exc
329
+ original_paths = metadata.get("original_paths") or []
330
+ if not original_paths:
331
+ raise MangaColorizerError("No hay imagenes en cache.")
332
+ return metadata
333
+
334
+
335
+ def get_colorized_path(original_path: str) -> Path:
336
+ source = Path(original_path)
337
+ colorized_dir = source.parent.parent / "colorized"
338
+ colorized_dir.mkdir(parents=True, exist_ok=True)
339
+ return colorized_dir / f"{source.stem}_colorized.png"
340
+
341
+
342
+ def make_colorized_zip(colorized_paths: list[str]) -> str:
343
+ valid_paths = [Path(path) for path in colorized_paths if path and Path(path).exists()]
344
+ if not valid_paths:
345
+ raise MangaColorizerError("No hay imagenes coloreadas para comprimir.")
346
+
347
+ zip_path = valid_paths[0].parent / "colorized_manga.zip"
348
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_file:
349
+ for path in valid_paths:
350
+ zip_file.write(path, arcname=path.name)
351
+ return str(zip_path)
352
+
353
+
354
+ def sample_extract_manga_image_urls_test() -> list[str]:
355
+ sample_html = """
356
+ <div id="1" style="height: 1280px;">
357
+ <img class="img-fluid no-lazy" src="https://hmanga.asia/wp-content/uploads/mangas/methonium-porque-eres-tu-enano/02.webp">
358
+ </div>
359
+ """
360
+ return extract_manga_image_urls(sample_html, "https://hmanga.asia/porque-eres-tu-enano/#all")
requirements.txt CHANGED
@@ -11,4 +11,6 @@ hf_xet
11
  gradio
12
  torch==2.11.0
13
  numpy
14
- av
 
 
 
11
  gradio
12
  torch==2.11.0
13
  numpy
14
+ av
15
+ beautifulsoup4
16
+ requests