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:
2026-06-24 15:35:12 +02:00
parent bcf731275e
commit 6f4b440762
9 changed files with 1180 additions and 0 deletions
@@ -0,0 +1,84 @@
---
name: comfyui_extract_recipe_from_png
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_extract_recipe_from_png(png_path: str, *, slug: str | None = None, civitai_meta: dict | None = None, image_url: str = \"\", nsfw: bool = False) -> dict"
description: "Destila un PNG cosechado de Civitai en una receta de skill CANDIDATA (schema comfyui-skill, score_n=0, provenance.source='civitai'). Compone comfyui_import_workflow_png (workflow API format embebido) + comfyui_read_png_metadata (params del KSampler) y, como fallback para samplers no-KSampler (flux/SamplerCustomAdvanced), extrae checkpoint del UNETLoader y prompts de los nodos CLIPTextEncode. Degradacion honesta: si el PNG no trae workflow embebido, usa la meta de Civitai (civitai_meta); si tampoco hay nada utilizable, ok=False sin inventar. Impura: lectura de disco."
tags: [comfyui, civitai, ml, recipe, png, metadata, comfyui-skill]
uses_functions: ["comfyui_import_workflow_png_py_ml", "comfyui_read_png_metadata_py_ml"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["os", "re", "sys"]
params:
- name: png_path
desc: "Ruta del PNG descargado de Civitai."
- name: slug
desc: "Slug de la skill candidata. Si None se deriva del prompt positivo (fallback 'civitai_import'). keyword-only."
- name: civitai_meta
desc: "dict meta del item de comfyui_search_civitai_images. Fallback si el PNG no trae workflow embebido, y para rellenar campos ausentes. keyword-only."
- name: image_url
desc: "URL de origen (se guarda en provenance). keyword-only."
- name: nsfw
desc: "Marca provenance.nsfw. keyword-only."
output: "dict {ok, recipe, slug, has_workflow, error}. recipe sigue el schema minimo de comfyui_save_skill con provenance.source='civitai' y score_n=0. ok=False solo si no hay ni workflow embebido ni civitai_meta utilizable."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_extract_recipe_from_png.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_extract_recipe_from_png import comfyui_extract_recipe_from_png
from ml.comfyui_save_skill import comfyui_save_skill
# PNG cosechado de Civitai (con comfyui_fetch_civitai_image).
res = comfyui_extract_recipe_from_png("/home/me/ComfyUI/civitai_harvest/abc.png",
image_url="https://civitai.com/...", nsfw=False)
print(res["ok"], res["has_workflow"], res["recipe"]["provenance"]["source"]) # True True civitai
# Round-trip: la receta candidata la acepta save_skill tal cual.
if res["ok"]:
comfyui_save_skill(res["recipe"], library_dir="/tmp/skills_test")
# Sin workflow embebido pero con meta de Civitai (degradacion honesta):
# comfyui_extract_recipe_from_png(png, civitai_meta={"prompt": "...", "Model": "x.safetensors", "steps": 25})
```
## Cuando usarla
Tras descargar un PNG de Civitai, para convertirlo en una receta de skill
candidata lista para `comfyui_save_skill` (score_n=0) sin reconstruirla a mano. El
pipeline `comfyui_harvest_civitai_skill_oneshot` la encadena con el search y el
fetch.
## Gotchas
- **El prompt cosechado es CONCRETO, no un scaffold con `{subject}`**: se guarda
verbatim en `prompt_scaffold.positive` como punto de partida. Sustituye partes
por `{subject}` a mano si quieres reutilizar la skill para otros sujetos.
- **Degradacion honesta**: si el PNG no trae workflow ComfyUI (chunks tEXt) y no
pasas `civitai_meta` con al menos prompt o modelo, devuelve `ok=False`. NO
inventa una receta.
- **Civitai casi siempre da `meta` null hoy**: el camino principal es el workflow
embebido en el PNG. La `meta` es fallback que en la practica rara vez ayuda.
- **Heuristica de prompt en samplers no-KSampler** (flux/SamplerCustomAdvanced):
el positivo se asume el CLIPTextEncode mas largo y el negativo el que contenga
terminos tipicos (blurry, bad anatomy, ...). Puede no acertar en workflows con
muchos encoders — es best-effort, por eso la skill nace como CANDIDATA a juzgar.
- **El checkpoint puede salir vacio** si el workflow usa un loader exotico; la
receta se guarda igual (slug/base_workflow/version bastan para save_skill) pero
conviene completarla antes de generar.
- **base_workflow se detecta** (txt2img/flux) por los class_type; revisalo si vas a
compilar con `comfyui_build_skill_workflow`.
- El PNG puede traer prompts NSFW: marca `nsfw=True` para que quede en
`provenance.nsfw`.
- Impura: lee disco (via las dos funciones que compone).
@@ -0,0 +1,257 @@
"""Destila un PNG cosechado de Civitai en una receta de skill candidata.
Compone `comfyui_import_workflow_png` (workflow API format embebido en los chunks
de texto) + `comfyui_read_png_metadata` (parámetros del KSampler) para producir
una receta del grupo `comfyui-skill` lista para `comfyui_save_skill`: checkpoint,
loras, params, prompt_scaffold, `provenance.source='civitai'` y `score_n=0` (es
una candidata, no validada).
**Degradación honesta**: si el PNG NO trae chunk de workflow ComfyUI (Civitai sirve
muchas imágenes recomprimidas a JPEG sin metadata), cae a la `meta` de generación
que Civitai entrega aparte (pásala con `civitai_meta`). Si tampoco hay nada
utilizable, devuelve `ok=False` sin inventar.
Impura: lectura de disco (vía las dos funciones que compone). Solo stdlib.
"""
import os
import re
import sys
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
from comfyui_import_workflow_png import comfyui_import_workflow_png # noqa: E402
from comfyui_read_png_metadata import comfyui_read_png_metadata # noqa: E402
def _slugify(text: str, fallback: str) -> str:
"""Convierte texto libre a un slug válido [a-z0-9_] no vacío."""
s = re.sub(r"[^a-z0-9]+", "_", (text or "").lower()).strip("_")
s = "_".join(s.split("_")[:6]) # acota a 6 tokens
return s or fallback
def _loras_from_prompt(prompt: dict) -> list:
"""Extrae los LoRAs de los nodos LoraLoader del workflow API format."""
loras = []
for node in (prompt or {}).values():
if not isinstance(node, dict):
continue
if str(node.get("class_type", "")).startswith("LoraLoader"):
ins = node.get("inputs", {})
name = ins.get("lora_name")
if isinstance(name, str) and name:
loras.append({
"name": name,
"strength_model": ins.get("strength_model", 1.0)
if not isinstance(ins.get("strength_model"), list) else 1.0,
"strength_clip": ins.get("strength_clip", 1.0)
if not isinstance(ins.get("strength_clip"), list) else 1.0,
})
return loras
def _dims_from_prompt(prompt: dict) -> dict:
"""Saca width/height del primer EmptyLatentImage del workflow."""
for node in (prompt or {}).values():
if isinstance(node, dict) and "EmptyLatentImage" in str(node.get("class_type", "")):
ins = node.get("inputs", {})
w, h = ins.get("width"), ins.get("height")
out = {}
if isinstance(w, (int, float)) and not isinstance(w, bool):
out["width"] = int(w)
if isinstance(h, (int, float)) and not isinstance(h, bool):
out["height"] = int(h)
return out
return {}
_NEG_HINTS = ("deformed", "bad anatomy", "bad hands", "blurry", "lowres", "low res",
"worst quality", "extra dots", "noise", "watermark", "jpeg artifacts",
"ugly", "disfigured", "mutated", "(worst quality")
def _checkpoint_from_prompt(prompt: dict) -> str:
"""ckpt_name de un CheckpointLoader o unet_name de un UNETLoader (flux)."""
for node in (prompt or {}).values():
if isinstance(node, dict) and str(node.get("class_type", "")).startswith("CheckpointLoader"):
ck = node.get("inputs", {}).get("ckpt_name")
if isinstance(ck, str) and ck:
return ck
for node in (prompt or {}).values():
if isinstance(node, dict) and "UNETLoader" in str(node.get("class_type", "")):
un = node.get("inputs", {}).get("unet_name")
if isinstance(un, str) and un:
return un
return ""
def _prompts_from_clip_nodes(prompt: dict) -> tuple:
"""Positive/negative heurístico desde los CLIPTextEncode (samplers no-KSampler).
El positivo se asume el texto literal más largo; el negativo el que contenga
términos típicos de prompt negativo. Best-effort: workflows con muchos
encoders pueden no acertar, por eso es solo fallback de read_png_metadata.
"""
texts = []
for node in (prompt or {}).values():
if not isinstance(node, dict):
continue
if "CLIPTextEncode" in str(node.get("class_type", "")) or "TextEncode" in str(node.get("class_type", "")):
t = node.get("inputs", {}).get("text")
if isinstance(t, str) and t.strip():
texts.append(t.strip())
if not texts:
return "", ""
negatives = [t for t in texts if any(h in t.lower() for h in _NEG_HINTS)]
positives = [t for t in texts if t not in negatives]
positive = max(positives, key=len) if positives else max(texts, key=len)
negative = max(negatives, key=len) if negatives else ""
return positive, negative
def _detect_base_workflow(prompt: dict) -> str:
"""txt2img / flux según los class_type presentes (best-effort)."""
for node in (prompt or {}).values():
if isinstance(node, dict):
ct = str(node.get("class_type", ""))
if "Flux" in ct or ct in ("UNETLoader", "DualCLIPLoader"):
return "flux"
return "txt2img"
def _from_civitai_meta(meta: dict) -> dict:
"""Mapea la `meta` de generación de Civitai a campos de receta (fallback)."""
meta = meta or {}
params = {}
for src, dst in (("steps", "steps"), ("sampler", "sampler_name"),
("Size", "_size"), ("seed", "seed")):
if src in meta:
params[dst] = meta[src]
if "cfgScale" in meta:
params["cfg"] = meta["cfgScale"]
# Size viene como "832x1216".
size = params.pop("_size", None)
if isinstance(size, str) and "x" in size:
try:
w, h = size.lower().split("x", 1)
params["width"], params["height"] = int(w), int(h)
except ValueError:
pass
loras = []
for res in (meta.get("resources") or meta.get("civitaiResources") or []):
if isinstance(res, dict) and str(res.get("type", "")).lower() == "lora":
nm = res.get("name") or res.get("modelName")
if nm:
loras.append({"name": nm,
"strength_model": res.get("weight", 1.0) or 1.0,
"strength_clip": res.get("weight", 1.0) or 1.0})
return {
"checkpoint": meta.get("Model") or meta.get("model") or "",
"loras": loras,
"params": params,
"positive": meta.get("prompt", "") or "",
"negative": meta.get("negativePrompt", "") or "",
}
def comfyui_extract_recipe_from_png(
png_path: str,
*,
slug: str | None = None,
civitai_meta: dict | None = None,
image_url: str = "",
nsfw: bool = False,
) -> dict:
"""Destila un PNG cosechado en una receta de skill candidata (schema `comfyui-skill`).
Args:
png_path: ruta del PNG descargado de Civitai.
slug: slug de la skill candidata. Si None se deriva del prompt positivo
(fallback `civitai_import`). keyword-only.
civitai_meta: dict `meta` del item de `comfyui_search_civitai_images`. Se
usa como fallback si el PNG no trae workflow embebido, y para rellenar
campos ausentes. keyword-only.
image_url: URL de origen (se guarda en `provenance`). keyword-only.
nsfw: marca `provenance.nsfw`. keyword-only.
Returns:
dict {ok, recipe, slug, has_workflow, error}. recipe sigue el schema mínimo
de `comfyui_save_skill` con `provenance.source='civitai'` y `score_n=0`.
ok=False solo si no hay ni workflow embebido ni `civitai_meta` utilizable.
"""
imp = comfyui_import_workflow_png(png_path)
meta_res = comfyui_read_png_metadata(png_path)
has_workflow = bool(imp.get("ok"))
prompt = imp.get("prompt") or {}
png_params = meta_res.get("parameters") or {}
fallback = _from_civitai_meta(civitai_meta or {})
if not has_workflow and not (fallback["positive"] or fallback["checkpoint"]):
return {"ok": False, "recipe": {}, "slug": slug or "", "has_workflow": False,
"error": ("el PNG no trae workflow ComfyUI embebido y no se aportó "
"civitai_meta utilizable (sin prompt ni modelo).")}
# Prompts genéricos del workflow (cubre samplers no-KSampler como flux).
clip_pos, clip_neg = _prompts_from_clip_nodes(prompt)
# Checkpoint: KSampler/PNG → CheckpointLoader/UNETLoader del grafo → meta Civitai.
checkpoint = png_params.get("model") or _checkpoint_from_prompt(prompt) or fallback["checkpoint"] or ""
# Params: combinar los del KSampler del PNG con dims del workflow / meta.
params = {}
for k in ("steps", "cfg", "sampler_name", "scheduler", "denoise", "seed"):
if k in png_params:
params[k] = png_params[k]
params.update(_dims_from_prompt(prompt))
for k, v in fallback["params"].items():
params.setdefault(k, v)
positive = png_params.get("positive") or clip_pos or fallback["positive"] or ""
negative = png_params.get("negative") or clip_neg or fallback["negative"] or ""
loras = _loras_from_prompt(prompt) or fallback["loras"]
derived_slug = slug or _slugify(positive, "civitai_import")
recipe = {
"schema_version": 1,
"slug": derived_slug,
"version": "1.0.0",
"title": positive[:60] if positive else f"Civitai import {derived_slug}",
"base_workflow": _detect_base_workflow(prompt),
"checkpoint": checkpoint,
"loras": loras,
"params": params,
"prompt_scaffold": {
# El prompt cosechado es concreto; el caller puede sustituir partes por
# {subject} para reutilizarlo. Se conserva verbatim como punto de partida.
"positive": positive,
"negative": negative,
"trigger_words": [],
},
"blocks": [],
"score_mean": 0.0,
"score_n": 0,
"provenance": {
"source": "civitai",
"nsfw": bool(nsfw),
"image_url": image_url,
"png_path": png_path,
"workflow_embedded": has_workflow,
},
}
return {"ok": True, "recipe": recipe, "slug": derived_slug,
"has_workflow": has_workflow, "error": ""}
if __name__ == "__main__":
import json
import sys
path = sys.argv[1] if len(sys.argv) > 1 else "/tmp/missing.png"
res = comfyui_extract_recipe_from_png(path, slug="smoke_civitai")
print(json.dumps({"ok": res["ok"], "slug": res["slug"],
"has_workflow": res["has_workflow"], "error": res["error"]},
ensure_ascii=False, indent=2))
@@ -0,0 +1,80 @@
---
name: comfyui_fetch_civitai_image
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "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"
description: "Descarga el PNG de una imagen de Civitai a disco, SEGREGANDO el NSFW a una subcarpeta marcada (<dest_dir>/<nsfw_subdir>/). Reescribe la URL redimensionada (/width=N/) a la original (/original=true/) para conservar el workflow ComfyUI embebido. Aplica la misma validacion no-HTML que comfyui_download_model (rechaza paginas de error/login de Cloudflare disfrazadas de imagen) y nombra el archivo por el UUID de la imagen para evitar colisiones. Impura: HTTP GET + escritura en disco."
tags: [comfyui, civitai, ml, download, images, nsfw, http]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["os", "re", "urllib.error", "urllib.parse", "urllib.request"]
params:
- name: image_url
desc: "URL de la imagen (campo url de comfyui_search_civitai_images)."
- name: dest_dir
desc: "Carpeta destino (se expande ~). Se crea si no existe. keyword-only, obligatorio."
- name: nsfw
desc: "Si True, la imagen se guarda en <dest_dir>/<nsfw_subdir>/ en vez de directamente en dest_dir (segregacion obligatoria de NSFW). keyword-only."
- name: nsfw_subdir
desc: "Nombre de la subcarpeta para NSFW. Default 'nsfw'. keyword-only."
- name: token
desc: "Token Civitai (header Authorization Bearer). Algunas imagenes lo exigen para el original. None lo omite. No hardcodear. keyword-only."
- name: prefer_original
desc: "Si True (default) reescribe /width=N/ a /original=true/ para conservar el workflow embebido. keyword-only."
- name: timeout_s
desc: "Timeout HTTP en segundos. keyword-only."
output: "dict {ok, path, size_bytes, nsfw, error}. ok=False si la respuesta era HTML de error, demasiado pequena, o fallo la red/escritura (sin dejar basura en disco). nsfw refleja la carpeta usada."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_fetch_civitai_image.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_search_civitai_images import comfyui_search_civitai_images
from ml.comfyui_fetch_civitai_image import comfyui_fetch_civitai_image
sr = comfyui_search_civitai_images(nsfw="None", sort="Most Reactions", limit=5)
item = sr["items"][0]
# Imagen SFW -> directamente en dest_dir.
res = comfyui_fetch_civitai_image(item["url"], dest_dir=os.path.expanduser("~/ComfyUI/civitai_harvest"),
nsfw=item["nsfw"])
print(res["ok"], res["path"]) # ~/ComfyUI/civitai_harvest/<uuid>.png
# Imagen NSFW -> segregada a la subcarpeta nsfw/.
# comfyui_fetch_civitai_image(url, dest_dir="...", nsfw=True) -> .../nsfw/<uuid>.png
```
## Cuando usarla
Tras `comfyui_search_civitai_images`, para bajar el PNG original de una imagen (con
su workflow ComfyUI embebido) y luego destilarlo con
`comfyui_extract_recipe_from_png`. Pasa `nsfw=True` (del item del search) para que
el contenido adulto quede SIEMPRE en su carpeta separada.
## Gotchas
- **Segregacion NSFW**: la politica del sistema permite NSFW pero SIEMPRE separado.
Pasa `nsfw=True` y la imagen va a `<dest_dir>/nsfw/`; nunca mezcles adulto y SFW
en la misma carpeta.
- **El PNG es un secreto potencial**: el workflow embebido puede traer prompts
NSFW y, ocasionalmente, rutas locales del autor. Trata el archivo como dato (no
lo commitees; vive fuera del repo, en `~/ComfyUI/`).
- **No todas las imagenes traen workflow ComfyUI**: Civitai recomprime muchas a
JPEG (sin chunks tEXt) o son de A1111. La descarga funciona igual; la extraccion
posterior degradara. Por eso el pipeline itera varios items.
- `prefer_original=True` reescribe `/width=N/` a `/original=true/` para maximizar
conservar la metadata; si la URL ya es original, la deja igual.
- Valida que la respuesta no sea HTML (Cloudflare/login) ni < 1 KB: en esos casos
devuelve `ok=False` y NO deja basura en disco.
- Impura: HTTP GET + escritura en disco. Requiere internet.
@@ -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))
@@ -0,0 +1,88 @@
---
name: comfyui_search_civitai_images
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_search_civitai_images(*, query: str | None = None, model_version_id: int | None = None, nsfw: str = \"None\", sort: str = \"Most Reactions\", limit: int = 20, token: str | None = None) -> dict"
description: "Busca imagenes en Civitai via GET /api/v1/images y normaliza cada item a {id, url, width, height, nsfw, nsfw_level, base_model, model_version_ids, meta}. El endpoint NO admite busqueda textual (un query crudo da HTTP 500): cuando se pasa query sin model_version_id, resuelve el query a una version de modelo con comfyui_search_civitai_models y consulta las imagenes de esa version (filtro fiable); si no casa modelo, cae al feed global. El token se resuelve de pass civitai/api-token si no se pasa. Reintenta 503 transitorios. Impura: HTTP + subprocess (pass)."
tags: [comfyui, civitai, ml, search, images, stable-diffusion, http]
uses_functions: ["comfyui_search_civitai_models_py_ml"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "os", "subprocess", "sys", "time", "urllib.error", "urllib.parse", "urllib.request"]
params:
- name: query
desc: "Texto de busqueda libre. El endpoint de imagenes no tiene busqueda textual; se resuelve a una version de modelo via comfyui_search_civitai_models (matchea NOMBRES de modelo, no temas). keyword-only."
- name: model_version_id
desc: "Filtra por una version de modelo concreta (el version_id de comfyui_search_civitai_models). Es el filtro fiable. None no filtra. keyword-only."
- name: nsfw
desc: "Nivel NSFW Civitai: 'None' (solo SFW, default), 'Soft', 'Mature', 'X', o 'true'/'false'. keyword-only."
- name: sort
desc: "Orden Civitai: 'Most Reactions' (default), 'Most Comments', 'Newest'. keyword-only."
- name: limit
desc: "Numero maximo de resultados (1-200). keyword-only."
- name: token
desc: "API token de Civitai (header Authorization Bearer). Si None se lee de pass civitai/api-token. No hardcodear. keyword-only."
output: "dict {ok, items, count, resolved_model_version_id, error}. items = lista de {id, url, width, height, nsfw, nsfw_level, base_model, model_version_ids, meta}. ok=False con error si la peticion falla; 0 resultados devuelve ok=True con items=[] (no es error)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_search_civitai_images.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_search_civitai_images import comfyui_search_civitai_images
# Feed global SFW ordenado por reacciones (donde abundan los workflows ComfyUI):
out = comfyui_search_civitai_images(nsfw="None", sort="Most Reactions", limit=10)
for it in out["items"]:
print(it["id"], it["nsfw"], it["base_model"], "->", it["url"])
# Con query: se resuelve a una version de modelo (matchea nombres de modelo).
out = comfyui_search_civitai_images(query="dreamshaper", nsfw="None", limit=5)
print("resuelto a version:", out["resolved_model_version_id"])
# Imagenes de una version concreta (filtro fiable):
out = comfyui_search_civitai_images(model_version_id=128713, nsfw="None", limit=5)
```
Cada `url` se descarga con `comfyui_fetch_civitai_image` y se destila con
`comfyui_extract_recipe_from_png` para obtener la receta.
## Cuando usarla
Cuando quieras cosechar de Civitai imagenes con su workflow ComfyUI embebido para
clonar su calidad y alimentar la libreria de skills (grupo `comfyui-skill`).
Empieza por el feed global `sort="Most Reactions"` si buscas workflows ComfyUI
completos; usa `model_version_id` si quieres ceñirte a un modelo concreto. El
pipeline `comfyui_harvest_civitai_skill_oneshot` la encadena entera.
## Gotchas
- **El endpoint de imagenes NO admite `query` textual**: enviarlo crudo devuelve
HTTP 500 ("Service Unavailable"). Por eso esta funcion lo resuelve a una version
de modelo. El query matchea NOMBRES de modelo, no temas ("portrait"/"cinematic"
devuelven 0 modelos → se usa el feed global).
- **La API ya NO expone `meta`**: hoy `meta` viene casi siempre `null` (cambio de
politica/privacidad de Civitai). La receta real NO sale de aqui — sale del
workflow embebido en el PNG (chunks tEXt), que destila
`comfyui_extract_recipe_from_png`. El campo `meta` se conserva por si algun item
lo trae.
- **El feed por version de modelo (SD1.5) rara vez trae workflow ComfyUI**: esas
imagenes suelen ser de A1111 o recomprimidas. Los workflows ComfyUI completos
abundan en el feed global de usuarios flux. El pipeline tiene un 2o pase global
por esto.
- Impura: HTTP GET a `civitai.com` + `subprocess` a `pass`. Requiere internet.
Reintenta 502/503/429 con backoff (la API es intermitente bajo carga).
- El PNG/URL cosechado puede traer prompts NSFW: clasifica con el campo `nsfw` y
segrega al descargar (`comfyui_fetch_civitai_image(nsfw=True)`).
- El token es secreto: NO lo pongas en claro; viene de `pass civitai/api-token`.
- 0 resultados NO es error: devuelve `ok=True, items=[], count=0`.
@@ -0,0 +1,177 @@
"""Busca imágenes en Civitai via su API pública GET /api/v1/images.
Hermana de `comfyui_search_civitai_models` pero para imágenes: devuelve cada
resultado con la `url` de su PNG (que suele traer el workflow ComfyUI embebido en
los chunks de texto). Permite filtrar NSFW y ordenar. El token se resuelve desde
`pass civitai/api-token` si no se pasa explícitamente.
Sirve para cosechar de Civitai imágenes con su receta para clonar su calidad y
alimentar la librería de skills (grupo `comfyui-skill`): cada `url` se descarga
con `comfyui_fetch_civitai_image` y se destila con `comfyui_extract_recipe_from_png`
(la receta sale del workflow embebido en el PNG, ver Gotchas).
El endpoint de imágenes de Civitai NO admite búsqueda textual (un `query` crudo
devuelve HTTP 500). Por eso, cuando se pasa `query` sin `model_version_id`, esta
función primero resuelve el query a una versión de modelo con
`comfyui_search_civitai_models` y consulta las imágenes de esa versión (filtro
fiable). Si el query no casa ningún modelo, cae al feed global ordenado
(best-effort, ver Gotchas).
Impura: red (HTTP GET a civitai.com) + subprocess (`pass`). Solo stdlib salvo la
función hermana del registry que compone.
"""
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
from comfyui_search_civitai_models import comfyui_search_civitai_models # noqa: E402
_API = "https://civitai.com/api/v1/images"
_TIMEOUT = 30.0
_RETRIES = 3 # la API de imágenes devuelve 503 transitorios bajo carga
def _resolve_token(token: str | None) -> str | None:
"""Devuelve el token explícito o lo lee de `pass civitai/api-token` (best-effort)."""
if token:
return token
try:
out = subprocess.run(
["pass", "civitai/api-token"],
capture_output=True, text=True, timeout=10,
)
if out.returncode == 0:
lines = out.stdout.strip().splitlines()
return lines[0].strip() if lines and lines[0].strip() else None
except Exception: # noqa: BLE001 — pass no instalado / entrada inexistente
pass
return None
def comfyui_search_civitai_images(
*,
query: str | None = None,
model_version_id: int | None = None,
nsfw: str = "None",
sort: str = "Most Reactions",
limit: int = 20,
token: str | None = None,
) -> dict:
"""Busca imágenes en Civitai y devuelve resultados normalizados.
Args:
query: texto de búsqueda libre. La API de imágenes de Civitai NO tiene un
parámetro de búsqueda textual fuerte (a diferencia de la de modelos):
se envía best-effort y puede ser ignorado por el servidor. Para
filtrar de verdad por un modelo concreto usa `model_version_id`.
keyword-only.
model_version_id: filtra las imágenes generadas con una versión de modelo
concreta (el `version_id` que devuelve `comfyui_search_civitai_models`).
Es el filtro fiable. None no filtra. keyword-only.
nsfw: nivel NSFW Civitai: "None" (solo SFW, default), "Soft", "Mature",
"X", o "true"/"false". keyword-only.
sort: orden Civitai: "Most Reactions" (default), "Most Comments", "Newest".
keyword-only.
limit: número máximo de resultados (1-200). keyword-only.
token: API token de Civitai (header Authorization Bearer). Si None se
resuelve de `pass civitai/api-token`. No hardcodear. keyword-only.
Returns:
dict {ok, items, count, resolved_model_version_id, error}. items = lista de
{id, url, width, height, nsfw, nsfw_level, base_model, model_version_ids,
meta}. `meta` es el dict de generación de Civitai SI lo expone (hoy suele
venir null, ver Gotchas); la receta real se destila del PNG embebido.
`resolved_model_version_id` indica a qué versión se resolvió un `query`.
ok=False con error si la petición falla; 0 resultados devuelve ok=True con
items=[] (no es error).
"""
tok = _resolve_token(token)
# El endpoint /images no admite query textual: resolver query → versión de modelo.
resolved_vid = model_version_id
if resolved_vid is None and query:
mr = comfyui_search_civitai_models(query, types="Checkpoint", limit=1, token=tok)
if mr.get("ok") and mr.get("items"):
resolved_vid = mr["items"][0].get("version_id")
params = [
("limit", str(max(1, min(int(limit), 200)))),
("sort", sort),
("nsfw", str(nsfw)),
]
if resolved_vid is not None:
params.append(("modelVersionId", str(resolved_vid)))
url = f"{_API}?{urllib.parse.urlencode(params)}"
headers = {"User-Agent": "fn-registry/comfyui_search_civitai_images"}
if tok:
headers["Authorization"] = f"Bearer {tok}"
data = None
last_err = ""
for attempt in range(_RETRIES):
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
data = json.loads(resp.read())
break
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace")[:300]
last_err = f"HTTP {exc.code} en /api/v1/images: {body}"
if exc.code in (502, 503, 429) and attempt < _RETRIES - 1:
time.sleep(1.5 * (attempt + 1))
continue
return {"ok": False, "items": [], "count": 0,
"resolved_model_version_id": resolved_vid, "error": last_err}
except urllib.error.URLError as exc:
return {"ok": False, "items": [], "count": 0,
"resolved_model_version_id": resolved_vid,
"error": f"no se pudo conectar a civitai.com: {exc.reason}"}
except json.JSONDecodeError as exc:
return {"ok": False, "items": [], "count": 0,
"resolved_model_version_id": resolved_vid,
"error": f"respuesta no es JSON válido: {exc}"}
if data is None:
return {"ok": False, "items": [], "count": 0,
"resolved_model_version_id": resolved_vid,
"error": last_err or "sin respuesta tras reintentos"}
items = []
for img in data.get("items", []) or []:
lvl = img.get("nsfwLevel")
is_nsfw = bool(img.get("nsfw", False))
if isinstance(lvl, str) and lvl not in ("", "None"):
is_nsfw = True
elif isinstance(lvl, (int, float)) and not isinstance(lvl, bool) and lvl > 1:
is_nsfw = True
items.append({
"id": img.get("id"),
"url": img.get("url"),
"width": img.get("width"),
"height": img.get("height"),
"nsfw": is_nsfw,
"nsfw_level": lvl,
"base_model": img.get("baseModel"),
"model_version_ids": img.get("modelVersionIds") or [],
"meta": img.get("meta") or {},
})
return {"ok": True, "items": items, "count": len(items),
"resolved_model_version_id": resolved_vid, "error": ""}
if __name__ == "__main__":
out = comfyui_search_civitai_images(query="cinematic portrait", nsfw="None", limit=5)
print(out["ok"], out["count"])
for it in out["items"]:
meta = it["meta"]
prompt = (meta.get("prompt") or "")[:60]
print(f" img {it['id']} nsfw={it['nsfw']} model={meta.get('Model')!r} prompt={prompt!r}")