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
+17
View File
@@ -82,6 +82,23 @@ El **API format** (dict de nodos numerados que produce `build_txt2img_workflow`
| [comfyui_run_foreign_workflow_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_run_foreign_workflow_oneshot.md) | `run_foreign_workflow_oneshot(source, *, server, dest=None, output_kind='auto', install_nodes=False, node_repos=None, wait_timeout, civitai_token, hf_token) -> dict` | **Pipeline** para ejecutar un workflow ComfyUI **ajeno** end-to-end en una llamada: import (cualquier fuente) → resolve deps → (instala solo nodos confiables opt-in) → validate → submit → wait → fetch (imagen/vídeo/malla). **Gate de seguridad**: si faltan deps NO encola y las reporta en `missing`; nunca descarga modelos a ciegas. Compone `download_workflow` + `resolve_workflow_deps` + `install_custom_node` + `submit`/`wait` + `fetch_output_image/video/mesh`. Promoción del roadmap 0064/0087. Impuro. |
| [comfyui_list_installed_models_py_ml](../../python/functions/ml/comfyui_list_installed_models.md) | `list_installed_models(folder=None, comfyui_dir='~/ComfyUI') -> dict` | Lista modelos por carpeta resolviendo la ruta real de `extra_model_paths.yaml` (`/mnt/2tb/comfyui_models/`) + la nativa. Escaneo de FS, no depende del server. Impura. |
### Cosecha de Civitai → skills candidatas — dominio `ml` + `pipelines` (issue 0087)
Cosechar de Civitai imágenes con su workflow+receta embebidos para clonar su calidad y alimentar
la librería de skills (grupo [`comfyui-skill`](comfyui-skill.md)). En vez de reconstruir a mano una
receta que ya existe en una imagen pública, se cosecha y se guarda como **candidata** (`score_n=0`,
`provenance.source='civitai'`) para que el bucle de juicio/bump la valide. Política: **NSFW
permitido pero SIEMPRE segregado** en carpeta marcada. **Gotcha clave**: la API de Civitai ya no
expone `meta` (viene `null`) — la receta real sale del **workflow ComfyUI embebido en el PNG**, no
de la meta inline.
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_search_civitai_images_py_ml](../../python/functions/ml/comfyui_search_civitai_images.md) | `search_civitai_images(*, query=None, model_version_id=None, nsfw='None', sort='Most Reactions', limit=20, token=None) -> dict` | Busca imágenes en Civitai (GET /api/v1/images) → items con `url` (PNG con workflow embebido). El endpoint no admite query textual (HTTP 500): resuelve `query`→versión de modelo via `search_civitai_models`. Token de `pass civitai/api-token`. Reintenta 503. Impura. |
| [comfyui_fetch_civitai_image_py_ml](../../python/functions/ml/comfyui_fetch_civitai_image.md) | `fetch_civitai_image(image_url, *, dest_dir, nsfw=False, nsfw_subdir='nsfw', token=None, prefer_original=True, timeout_s=120) -> dict` | Descarga el PNG original (reescribe `/width=N/``/original=true/` para conservar el workflow), **segregando NSFW** a `<dest_dir>/nsfw/`. Misma validación no-HTML que `download_model`; nombra por UUID. Impura. |
| [comfyui_extract_recipe_from_png_py_ml](../../python/functions/ml/comfyui_extract_recipe_from_png.md) | `extract_recipe_from_png(png_path, *, slug=None, civitai_meta=None, image_url='', nsfw=False) -> dict` | Destila un PNG cosechado en receta de skill candidata (schema `comfyui-skill`, `source='civitai'`, `score_n=0`). Compone `import_workflow_png` + `read_png_metadata` + fallback de prompts/ckpt para flux. Sin workflow → usa `civitai_meta` (degradación honesta). Impura. |
| [comfyui_harvest_civitai_skill_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_harvest_civitai_skill_oneshot.md) | `harvest_civitai_skill_oneshot(*, query=None, model_version_id=None, nsfw='None', dest_dir, library_dir='~/ComfyUI/skills_library', comfyui_dir='~/ComfyUI', token=None, ...) -> dict` | **Pipeline** Civitai→skill candidata: search → fetch (segrega NSFW) → extract → save_skill. Itera items hasta uno con receta destilable (2º pase al feed global si filtró por modelo). **NO baja modelos a ciegas**: checkpoint/LoRA ausente → `missing_models`. Impuro. |
### Retoque pro y oneshot — dominio `ml` + `pipelines` (P0, lote report 0093)
Builders que envuelven custom-nodes "pro" ya instalados (Impact-Pack, UltimateSDUpscale) y la
@@ -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}")
@@ -0,0 +1,94 @@
---
name: comfyui_harvest_civitai_skill_oneshot
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def comfyui_harvest_civitai_skill_oneshot(*, query: str | None = None, model_version_id: int | None = None, nsfw: str = \"None\", dest_dir: str, library_dir: str = \"~/ComfyUI/skills_library\", comfyui_dir: str = \"~/ComfyUI\", token: str | None = None, slug: str | None = None, pick_index: int = 0, search_limit: int = 20) -> dict"
description: "One-shot Civitai -> skill candidata: search_images -> fetch (segrega NSFW) -> extract_recipe -> save_skill (candidata, score_n=0, provenance.source='civitai'). Itera los items hasta encontrar uno con receta destilable (preferentemente workflow ComfyUI embebido), descartando los PNG intermedios sin receta. Si se filtro por modelo y ninguno trae workflow, hace un 2o pase al feed global 'Most Reactions'. NO baja modelos a ciegas: si la receta referencia un checkpoint/LoRA ausente lo reporta en missing_models. Doctrina issue 0087 aplicada a cosechar assets de internet. Pipeline impuro: HTTP + escritura en disco."
tags: [comfyui, civitai, pipelines, ml, skill, harvest, comfyui-skill]
uses_functions: ["comfyui_search_civitai_images_py_ml", "comfyui_fetch_civitai_image_py_ml", "comfyui_extract_recipe_from_png_py_ml", "comfyui_save_skill_py_ml"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["os", "sys"]
params:
- name: query
desc: "Texto de busqueda (best-effort: se resuelve a una version de modelo via search). keyword-only."
- name: model_version_id
desc: "Filtra por una version de modelo concreta (filtro fiable). keyword-only."
- name: nsfw
desc: "Nivel NSFW del search Civitai ('None'=SFW por defecto, 'Soft'/'Mature'/'X'). keyword-only."
- name: dest_dir
desc: "Carpeta donde se descarga el PNG (NSFW se segrega a nsfw/). keyword-only, obligatorio."
- name: library_dir
desc: "Libreria de skills. Default ~/ComfyUI/skills_library. keyword-only."
- name: comfyui_dir
desc: "Raiz de ComfyUI para chequear modelos instalados (missing_models). keyword-only."
- name: token
desc: "Token Civitai. None lo resuelve de pass civitai/api-token. No hardcodear. keyword-only."
- name: slug
desc: "Slug de la skill. Si None se deriva del prompt cosechado. keyword-only."
- name: pick_index
desc: "Indice del item del search por el que empezar a iterar (default 0). keyword-only."
- name: search_limit
desc: "Cuantas imagenes pedir al search. keyword-only."
output: "dict {ok, slug, skill_path, png_path, recipe, nsfw, missing_models, image_id, has_workflow, attempts, error}. ok=False con error claro en fallo de red/guardado o si ningun item produjo receta; 0 resultados -> ok=False."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/comfyui_harvest_civitai_skill_oneshot.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions", "pipelines"))
from comfyui_harvest_civitai_skill_oneshot import comfyui_harvest_civitai_skill_oneshot
res = comfyui_harvest_civitai_skill_oneshot(
query="cinematic portrait", nsfw="None",
dest_dir=os.path.expanduser("~/ComfyUI/civitai_harvest"),
)
print(res["ok"], res["slug"], "workflow_embebido=", res["has_workflow"])
print("guardada en:", res["skill_path"]) # ~/ComfyUI/skills_library/<slug>/recipe.json
print("modelos ausentes:", res["missing_models"]) # checkpoints/LoRAs a bajar (NO bajados)
# La receta queda como CANDIDATA (score_n=0). El bucle del grupo comfyui-skill
# (generate -> judge -> bump) la valida y promueve si funciona.
```
## Cuando usarla
Cuando quieras cosechar de Civitai una imagen con su workflow+receta embebidos y
guardarla como skill candidata de un tiron, sin reconstruir la receta a mano. Es
la 3a pieza del sistema `comfyui-skill`: alimenta la libreria con recetas reales
de internet para clonar su calidad. Luego juzga/promueve con
`comfyui_generate_with_skill_oneshot` + `comfyui_bump_skill_version`.
## Gotchas
- **NO baja modelos a ciegas**: si la receta cosechada referencia un checkpoint o
LoRA no instalado en `<comfyui_dir>/models/`, lo lista en `missing_models` y NO
descarga nada. Bajarlos (con `comfyui_search_civitai_models` +
`comfyui_download_model`) es una decision aparte del caller.
- **NSFW segregado**: el PNG cosechado se descarga a `<dest_dir>/nsfw/` si el item
es NSFW (politica del sistema: permitido pero SIEMPRE separado). El `dest_dir`
vive fuera del repo (`~/ComfyUI/`) y NO se commitea: trata las imagenes como
datos.
- **Itera y descarta**: como muchas imagenes de Civitai no traen workflow ComfyUI
(JPEG recomprimido / A1111), el pipeline prueba varios items y borra los PNG sin
receta. `attempts` indica cuantos probo; `has_workflow` si la elegida traia el
grafo embebido.
- **2o pase global**: si filtras por un modelo (query resuelto o model_version_id)
y ninguna imagen de esa version trae workflow, reintenta en el feed global "Most
Reactions" (donde abundan los workflows ComfyUI de usuarios flux). Por eso un
query tematico acaba dando candidata aunque no filtre fuerte.
- **La skill nace CANDIDATA** (`score_n=0`, `provenance.source='civitai'`): no esta
validada. El prompt es concreto, no un scaffold con `{subject}`.
- El token es secreto: viene de `pass civitai/api-token`, nunca en claro.
- Pipeline impuro: HTTP + escritura en disco. Requiere internet y la libreria de
skills es estado local (no se indexa, vive bajo `~/ComfyUI/skills_library`).
@@ -0,0 +1,219 @@
"""comfyui_harvest_civitai_skill_oneshot — Civitai → skill candidata en una llamada.
Cosecha de Civitai una imagen con su workflow+receta embebidos y la promueve a una
skill candidata de la librería (grupo `comfyui-skill`), en un solo paso:
search_images → fetch (segrega NSFW) → extract_recipe → save_skill (candidata)
Es la doctrina del issue 0087 aplicada a la captación de assets de internet: en vez
de reconstruir a mano una receta que ya existe en una imagen pública, se cosecha y
se guarda como candidata (`score_n=0`, `provenance.source='civitai'`) para que el
bucle de juicio/bump del grupo `comfyui-skill` la valide y la promueva si funciona.
Compone funciones del registry:
comfyui_search_civitai_images_py_ml (GET /api/v1/images)
comfyui_fetch_civitai_image_py_ml (descarga PNG, segrega NSFW)
comfyui_extract_recipe_from_png_py_ml (destila receta candidata)
comfyui_save_skill_py_ml (persiste en la librería)
Pipeline impuro: red (HTTP) + escritura en disco.
**No baja modelos a ciegas**: si la receta cosechada referencia un checkpoint o LoRA
que NO está instalado en `<comfyui_dir>/models/`, lo reporta en `missing_models` y NO
descarga nada. Bajar esos modelos (con `comfyui_search_civitai_models` +
`comfyui_download_model`) es una decisión aparte del caller.
"""
from __future__ import annotations
import os
import sys
_FUNCTIONS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _FUNCTIONS_ROOT not in sys.path:
sys.path.insert(0, _FUNCTIONS_ROOT)
from ml.comfyui_extract_recipe_from_png import comfyui_extract_recipe_from_png
from ml.comfyui_fetch_civitai_image import comfyui_fetch_civitai_image
from ml.comfyui_save_skill import comfyui_save_skill
from ml.comfyui_search_civitai_images import comfyui_search_civitai_images
def _installed_models(comfyui_dir: str) -> set:
"""Conjunto de nombres de archivo de checkpoints + loras instalados."""
base = os.path.expanduser(comfyui_dir)
found = set()
for sub in ("checkpoints", "loras"):
d = os.path.join(base, "models", sub)
if os.path.isdir(d):
try:
found.update(os.listdir(d))
except OSError:
pass
return found
def _missing_models(recipe: dict, comfyui_dir: str) -> list:
"""Modelos referenciados por la receta que no están en disco (NO los descarga)."""
installed = _installed_models(comfyui_dir)
missing = []
ckpt = recipe.get("checkpoint")
if ckpt and os.path.basename(ckpt) not in installed:
missing.append({"kind": "checkpoint", "name": ckpt})
for lora in recipe.get("loras") or []:
nm = lora.get("name") if isinstance(lora, dict) else None
if nm and os.path.basename(nm) not in installed:
missing.append({"kind": "lora", "name": nm})
return missing
def comfyui_harvest_civitai_skill_oneshot(
*,
query: str | None = None,
model_version_id: int | None = None,
nsfw: str = "None",
dest_dir: str,
library_dir: str = "~/ComfyUI/skills_library",
comfyui_dir: str = "~/ComfyUI",
token: str | None = None,
slug: str | None = None,
pick_index: int = 0,
search_limit: int = 20,
) -> dict:
"""Cosecha una imagen de Civitai y la guarda como skill candidata.
Args:
query: texto de búsqueda libre (best-effort, ver search_civitai_images).
keyword-only.
model_version_id: filtra por versión de modelo (filtro fiable). keyword-only.
nsfw: nivel NSFW del search Civitai ("None"=SFW por defecto). keyword-only.
dest_dir: carpeta donde se descarga el PNG (NSFW se segrega a `nsfw/`).
keyword-only, obligatorio.
library_dir: librería de skills. Default `~/ComfyUI/skills_library`. keyword-only.
comfyui_dir: raíz de ComfyUI para chequear modelos instalados. keyword-only.
token: token Civitai. None lo resuelve de `pass civitai/api-token`. keyword-only.
slug: slug de la skill. Si None se deriva del prompt cosechado. keyword-only.
pick_index: índice del item del search a cosechar (default 0, el primero).
keyword-only.
search_limit: cuántas imágenes pedir al search. keyword-only.
Returns:
dict {ok, slug, skill_path, png_path, recipe, nsfw, missing_models,
image_id, has_workflow, attempts, error}. ok=False con error en cualquier
fallo de red/guardado; 0 resultados del search → ok=False con error claro.
Como Civitai sirve muchas imágenes recomprimidas sin workflow ComfyUI embebido
(y su API ya no expone `meta`), el pipeline ITERA los items desde `pick_index`
hasta encontrar el primero del que se puede destilar una receta, descartando
(borrando de disco) los PNGs intermedios que no sirven. Prefiere los que traen
workflow embebido.
"""
sr = comfyui_search_civitai_images(
query=query, model_version_id=model_version_id,
nsfw=nsfw, limit=search_limit, token=token,
)
if not sr.get("ok"):
return _err(f"search falló: {sr.get('error')}")
items = sr.get("items") or []
if not items:
return _err("el search no devolvió imágenes (0 resultados)")
if pick_index >= len(items):
return _err(f"pick_index {pick_index} fuera de rango ({len(items)} items)")
ctx = {"dest_dir": dest_dir, "library_dir": library_dir,
"comfyui_dir": comfyui_dir, "token": token, "slug": slug}
res = _harvest_from_items(items[pick_index:], ctx)
if res.get("ok"):
return res
# 2º pase: si se filtró por un modelo (query resuelto / model_version_id) y
# ninguna imagen traía workflow ComfyUI embebido, prueba el feed global
# "Most Reactions" — donde abundan los workflows ComfyUI completos.
used_model_filter = (model_version_id is not None) or bool(sr.get("resolved_model_version_id"))
if used_model_filter:
sr2 = comfyui_search_civitai_images(nsfw=nsfw, sort="Most Reactions",
limit=search_limit, token=token)
if sr2.get("ok") and sr2.get("items"):
res2 = _harvest_from_items(sr2["items"], ctx, prev_attempts=res.get("attempts", 0))
if res2.get("ok"):
return res2
res = res2
return res
def _harvest_from_items(items: list, ctx: dict, *, prev_attempts: int = 0) -> dict:
"""Itera items: fetch → extract → save. Devuelve la primera candidata o _err."""
attempts = prev_attempts
last_err = "ningún item produjo una receta destilable"
for item in items:
attempts += 1
img_url = item.get("url")
is_nsfw = bool(item.get("nsfw"))
if not img_url:
continue
fr = comfyui_fetch_civitai_image(img_url, dest_dir=ctx["dest_dir"],
nsfw=is_nsfw, token=ctx["token"])
if not fr.get("ok"):
last_err = f"fetch falló: {fr.get('error')}"
continue
png_path = fr["path"]
er = comfyui_extract_recipe_from_png(
png_path, slug=ctx["slug"], civitai_meta=item.get("meta"),
image_url=img_url, nsfw=is_nsfw,
)
if not er.get("ok"):
last_err = f"extracción falló: {er.get('error')}"
_cleanup(png_path) # PNG sin receta útil → no dejar basura
continue
recipe = er["recipe"]
missing = _missing_models(recipe, ctx["comfyui_dir"])
sv = comfyui_save_skill(recipe, library_dir=ctx["library_dir"])
if not sv.get("ok"):
return _err(f"save_skill falló: {sv.get('error')}",
png_path=png_path, recipe=recipe, nsfw=is_nsfw,
missing_models=missing, image_id=item.get("id"),
has_workflow=er.get("has_workflow"), attempts=attempts)
return {
"ok": True,
"slug": sv["slug"],
"skill_path": sv["path"],
"png_path": png_path,
"recipe": recipe,
"nsfw": is_nsfw,
"missing_models": missing,
"image_id": item.get("id"),
"has_workflow": er.get("has_workflow"),
"attempts": attempts,
"error": "",
}
return _err(f"tras {attempts} intentos, {last_err}", attempts=attempts)
def _cleanup(path: str | None) -> None:
if path and os.path.exists(path):
try:
os.remove(path)
except OSError:
pass
def _err(msg: str, **extra) -> dict:
base = {"ok": False, "slug": "", "skill_path": "", "png_path": "", "recipe": {},
"nsfw": False, "missing_models": [], "image_id": None,
"has_workflow": False, "attempts": 0, "error": msg}
base.update(extra)
return base
if __name__ == "__main__":
import json
out = comfyui_harvest_civitai_skill_oneshot(
query="cinematic portrait", nsfw="None",
dest_dir=os.path.expanduser("~/ComfyUI/civitai_harvest"),
)
print(json.dumps({k: v for k, v in out.items() if k != "recipe"},
ensure_ascii=False, indent=2))
if out.get("ok"):
print("missing_models:", out["missing_models"])