feat(ml): cosecha Civitai → skills candidatas (search/fetch/extract + harvest oneshot)
Cierra la 3ª pieza del sistema comfyui-skill: cosechar de Civitai imágenes con su workflow+receta embebidos para clonar su calidad y alimentar la librería de skills. - comfyui_search_civitai_images: GET /api/v1/images; resuelve query->versión de modelo (el endpoint no admite query textual, da HTTP 500); token de pass; reintenta 503. - comfyui_fetch_civitai_image: descarga el PNG original (conserva workflow embebido), SEGREGA NSFW a <dest>/nsfw/, validación no-HTML, nombre único por UUID. - comfyui_extract_recipe_from_png: import_workflow_png + read_png_metadata + fallback flux (CLIPTextEncode/UNETLoader) -> receta candidata (source='civitai', score_n=0). - comfyui_harvest_civitai_skill_oneshot (pipeline): search->fetch->extract->save_skill; itera items, 2º pase al feed global, NO baja modelos a ciegas (missing_models). Hallazgo: la API de Civitai ya no expone meta (null); la receta sale del workflow ComfyUI embebido en el PNG. Política: NSFW permitido pero SIEMPRE segregado. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
"""Descarga el PNG de una imagen de Civitai, segregando el NSFW a una subcarpeta.
|
||||
|
||||
Baja el binario de la imagen a `<dest_dir>/<filename>` (o, si `nsfw=True`, a
|
||||
`<dest_dir>/<nsfw_subdir>/<filename>`), aplicando la misma validación no-HTML que
|
||||
`comfyui_download_model` para no dejar páginas de error de Cloudflare/login
|
||||
disfrazadas de imagen. Las URLs de Civitai suelen apuntar a una variante
|
||||
redimensionada (`/width=N/`) que pierde los chunks de texto; por defecto se
|
||||
reescribe a la original (`/original=true/`) para conservar el workflow ComfyUI
|
||||
embebido que luego destila `comfyui_extract_recipe_from_png`.
|
||||
|
||||
**Segregación NSFW**: la política del sistema permite NSFW pero SIEMPRE separado en
|
||||
su propia carpeta marcada. El caller pasa `nsfw=True` (tomado del item de
|
||||
`comfyui_search_civitai_images`) y la función lo enruta a `nsfw_subdir`.
|
||||
|
||||
Impura: red (HTTP GET) + escritura en disco. Solo stdlib.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
_HTML_SNIFF = (b"<!doctype", b"<html", b"<head", b"<?xml")
|
||||
_WIDTH_RE = re.compile(r"/(?:width|height)=\d+/")
|
||||
_UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.I)
|
||||
|
||||
|
||||
def _to_original_url(url: str) -> str:
|
||||
"""Reescribe una URL de Civitai redimensionada a su original (best-effort)."""
|
||||
if _WIDTH_RE.search(url):
|
||||
return _WIDTH_RE.sub("/original=true/", url)
|
||||
return url
|
||||
|
||||
|
||||
def _derive_filename(url: str) -> str:
|
||||
"""Nombre único: <uuid-de-la-imagen>.<ext>, o el último segmento con extensión.
|
||||
|
||||
Las URLs de Civitai llevan el UUID de la imagen como segmento de ruta; usarlo
|
||||
como nombre garantiza unicidad y evita que dos cosechas colisionen en un
|
||||
genérico tipo "original.png".
|
||||
"""
|
||||
path = urllib.parse.urlparse(url).path
|
||||
segs = [s for s in path.split("/") if s and "=" not in s]
|
||||
ext = ".png"
|
||||
for seg in reversed(segs):
|
||||
if "." in seg and not seg.endswith("."):
|
||||
cand_ext = os.path.splitext(seg)[1].lower()
|
||||
if cand_ext in (".png", ".jpeg", ".jpg", ".webp"):
|
||||
ext = cand_ext
|
||||
break
|
||||
uuid = _UUID_RE.search(path)
|
||||
if uuid:
|
||||
return uuid.group(0) + ext
|
||||
for seg in reversed(segs):
|
||||
if "." in seg and not seg.endswith("."):
|
||||
return seg
|
||||
return (segs[-1] if segs else "civitai_image") + ext
|
||||
|
||||
|
||||
def comfyui_fetch_civitai_image(
|
||||
image_url: str,
|
||||
*,
|
||||
dest_dir: str,
|
||||
nsfw: bool = False,
|
||||
nsfw_subdir: str = "nsfw",
|
||||
token: str | None = None,
|
||||
prefer_original: bool = True,
|
||||
timeout_s: float = 120.0,
|
||||
) -> dict:
|
||||
"""Descarga el PNG de una imagen de Civitai a disco, segregando el NSFW.
|
||||
|
||||
Args:
|
||||
image_url: URL de la imagen (campo `url` de `comfyui_search_civitai_images`).
|
||||
dest_dir: carpeta destino (se expande ~). Se crea si no existe. keyword-only.
|
||||
nsfw: si True, la imagen se guarda en `<dest_dir>/<nsfw_subdir>/` en vez de
|
||||
directamente en `dest_dir`. keyword-only.
|
||||
nsfw_subdir: nombre de la subcarpeta para NSFW. Default "nsfw". keyword-only.
|
||||
token: token Civitai (header Authorization Bearer). Algunas imágenes lo
|
||||
exigen para servir el original. None lo omite. No hardcodear. keyword-only.
|
||||
prefer_original: si True (default) reescribe la URL `/width=N/` a
|
||||
`/original=true/` para conservar el workflow embebido. keyword-only.
|
||||
timeout_s: timeout HTTP en segundos. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict {ok, path, size_bytes, nsfw, error}. ok=False si la respuesta era HTML
|
||||
de error, demasiado pequeña, o falló la red/escritura (sin dejar basura en
|
||||
disco). `nsfw` refleja la carpeta usada.
|
||||
"""
|
||||
base = os.path.expanduser(dest_dir)
|
||||
target_dir = os.path.join(base, nsfw_subdir) if nsfw else base
|
||||
|
||||
req_url = _to_original_url(image_url) if prefer_original else image_url
|
||||
headers = {"User-Agent": "fn-registry/comfyui_fetch_civitai_image"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
tmp_path = None
|
||||
try:
|
||||
req = urllib.request.Request(req_url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
name = _derive_filename(resp.geturl()) or _derive_filename(image_url)
|
||||
|
||||
if "text/html" in content_type.lower():
|
||||
return {"ok": False, "path": "", "size_bytes": 0, "nsfw": nsfw,
|
||||
"error": (f"la respuesta es HTML (Content-Type: {content_type}), "
|
||||
"no una imagen. Revisa la URL/token.")}
|
||||
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
final_path = os.path.join(target_dir, name)
|
||||
tmp_path = final_path + ".part"
|
||||
|
||||
first = resp.read(512)
|
||||
low = first.lower().lstrip()
|
||||
if any(low.startswith(sig) for sig in _HTML_SNIFF):
|
||||
return {"ok": False, "path": "", "size_bytes": 0, "nsfw": nsfw,
|
||||
"error": "la respuesta empieza con HTML (página de error/login), no una imagen."}
|
||||
|
||||
size = 0
|
||||
with open(tmp_path, "wb") as fh:
|
||||
fh.write(first)
|
||||
size += len(first)
|
||||
while True:
|
||||
chunk = resp.read(1024 * 256)
|
||||
if not chunk:
|
||||
break
|
||||
fh.write(chunk)
|
||||
size += len(chunk)
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode(errors="replace")[:300]
|
||||
_cleanup(tmp_path)
|
||||
return {"ok": False, "path": "", "size_bytes": 0, "nsfw": nsfw,
|
||||
"error": f"HTTP {exc.code} en {image_url}: {body}"}
|
||||
except Exception as exc: # noqa: BLE001 — red/DNS/escritura
|
||||
_cleanup(tmp_path)
|
||||
return {"ok": False, "path": "", "size_bytes": 0, "nsfw": nsfw,
|
||||
"error": f"fallo descargando {image_url}: {exc}"}
|
||||
|
||||
if size < 1024:
|
||||
_cleanup(tmp_path)
|
||||
return {"ok": False, "path": "", "size_bytes": size, "nsfw": nsfw,
|
||||
"error": f"descarga sospechosamente pequeña ({size} bytes); probable error, no una imagen."}
|
||||
|
||||
os.replace(tmp_path, final_path)
|
||||
return {"ok": True, "path": final_path, "size_bytes": size, "nsfw": nsfw, "error": ""}
|
||||
|
||||
|
||||
def _cleanup(path: str | None) -> None:
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import sys
|
||||
|
||||
out = comfyui_fetch_civitai_image(
|
||||
sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8188/",
|
||||
dest_dir="/tmp/civitai_harvest_smoke",
|
||||
)
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
Reference in New Issue
Block a user