# Normaliza imágenes de catálogo en ERPNext
> Lee el doctype Item por la REST API de ERPNext con token, procesa la imagen con SocialCutter a las medidas de ecommerce y redes. Snippet en Python.
- URL: https://socialcutter.theboomer.dev/guias/erpnext/
- Idioma: es
- Familia: erp
- Actualizado: 2026-09-24
- Palabras clave: ERPNext, Frappe, REST API, doctype Item, catálogo, ecommerce, Python
## Por qué normalizar el catálogo

En ERPNext, la imagen del artículo suele venir de proveedores o de fotos propias con proporciones dispares. La misma foto debe servir para la ficha del artículo y para las redes, y cada canal pide un encuadre distinto. SocialCutter recorta de forma centrada a las medidas exactas de cada destino y devuelve una URL por salida; el campo `image` del artículo queda con la versión normalizada.

## Autenticación por token

ERPNext (Frappe) usa tokens de API. Se generan por usuario:

1. Abre el usuario en ERPNext y ve a **Settings → API Access**.
2. Genera **API Key** y **API Secret**.
3. Envía en cada petición la cabecera:

```
Authorization: token api_key:api_secret
```

La documentación oficial de la REST API está en https://docs.frappe.io/framework/user/en/api/rest. Los nombres de campo y el comportamiento de `upload_file` cambian entre versiones de Frappe (v13, v14, v15); confirma en tu instancia antes de automatizar en producción.

## Leer los artículos y su imagen

```python
import requests

BASE = "https://mi-erp.example.com"
HEADERS = {"Authorization": "token api_key:api_secret"}

params = {
    "fields": '["name","item_name","image"]',
    "filters": '[["image","!=",""]]',
    "limit_page_length": 50,
}
items = requests.get(f"{BASE}/api/resource/Item", headers=HEADERS, params=params, timeout=60)
items.raise_for_status()
items = items.json()["data"]
```

El campo `image` guarda una ruta relativa, por ejemplo `/files/producto-42.jpg`. Para descargar el fichero se antepone la base del sitio:

```python
for item in items:
    if not item["image"]:
        continue
    raw = requests.get(f"{BASE}{item['image']}", headers=HEADERS, timeout=60).content
```

## Procesar la imagen con SocialCutter

Los bytes ya están en memoria, así que se usa el endpoint multipart. Los destinos se eligen según dónde se publique el artículo:

```python
DESTINATIONS = [
    {"platform": "instagram", "format": "post"},   # 1080x1080
    {"platform": "linkedin", "format": "post"},    # 1200x627
]
```

Cada destino tiene medidas fijas conocidas, que sirven para registrar el resultado:

| Destino | Medidas |
|---|---|
| instagram post | 1080x1080 |
| facebook post | 1200x630 |
| linkedin post | 1200x627 |
| twitter post | 1200x675 |
| youtube thumbnail | 1280x720 |
| tiktok cover | 1080x1920 |

## El doctype File: upload_file, is_private y la carpeta pública

Subir un fichero a Frappe no es solo escribir bytes: cada subida crea un documento del doctype **File**. Sus campos relevantes para este flujo son:

| Campo | Tipo | Qué guarda |
|---|---|---|
| `file_url` | Data | La ruta, por ejemplo `/files/producto-42.jpg` |
| `file_name` | Data | El nombre del fichero |
| `is_private` | Check | `0` público, `1` privado |
| `attached_to_doctype` | Link | El doctype al que se adjunta (`Item`) |
| `attached_to_name` | Data | El documento concreto |
| `folder` | Link | Carpeta de File donde vive |
| `content_hash` | Data | Hash del contenido, para deduplicar |

### upload_file y sus parámetros

`POST /api/method/upload_file` acepta datos binarios y lee estos campos del formulario:

- `file`: el binario, en multipart.
- `doctype` y `docname`: a qué documento se adjunta.
- `is_private`: `0` o `1`.
- `file_url`: en lugar de `file`, para registrar una URL ya existente sin subir bytes.
- `filename`, `folder` y `docfield`: opcionales.

La respuesta trae `message.file_url`, `message.file_name` y `message.is_private`.

### Público o privado: dónde acaba el fichero

`is_private` decide la carpeta y la URL:

| `is_private` | Carpeta | URL | Acceso |
|---|---|---|---|
| `0` | `{site}/public/files/…` | `/files/producto-42.jpg` | Cualquiera con la URL, sin autenticación |
| `1` | `{site}/private/files/…` | `/private/files/producto-42.jpg` | Solo el propietario o quien tenga permiso de lectura sobre el documento enlazado |

Para una imagen de catálogo que va a verse en la web o en la tienda, la carpeta pública es la correcta: el campo `image` del artículo debe apuntar a una ruta servible. Reserva `is_private=1` para documentos internos. Si cambias `is_private` después, Frappe mueve el fichero de carpeta y reescribe su `file_url`.

### Crear el documento File por REST

Como cualquier doctype, `File` tiene su endpoint REST: `POST /api/resource/File`. Aquí los nombres son los del doctype, no los del formulario de `upload_file`: se envía `file_url` (o `content` con `decode=1` para el binario), `attached_to_doctype`, `attached_to_name` e `is_private`. Es la vía para registrar un fichero que ya existe por su URL sin descargarlo y volver a subirlo.

## Subir la imagen y escribir de vuelta

Dos pasos: primero se sube el fichero como adjunto y después se escribe su URL en el campo `image` del artículo.

```python
# 1. Subir el fichero procesado
upload = requests.post(
    f"{BASE}/api/method/upload_file",
    headers=HEADERS,
    files={"file": ("producto-42.jpg", img_bytes, "image/jpeg")},
    data={"doctype": "Item", "docname": item["name"], "is_private": 0},
    timeout=60,
)
upload.raise_for_status()
file_url = upload.json()["message"]["file_url"]

# 2. Escribir la URL en el campo image
requests.put(
    f"{BASE}/api/resource/Item/{item['name']}",
    headers=HEADERS,
    json={"image": file_url},
    timeout=60,
).raise_for_status()
```

La respuesta de SocialCutter trae `image_id` y un array `outputs` con la URL, la plataforma y el formato de cada salida. Se descarga la que se quiera como imagen del artículo.

ERPNext no guarda las medidas de la imagen en el artículo por defecto: son fijas por destino. Si necesitas conservarlas, usa un campo personalizado o el campo `description`.

## Snippet completo en Python

```python
import json

import requests

BASE = "https://mi-erp.example.com"
ERP_HEADERS = {"Authorization": "token api_key:api_secret"}

SC_URL = "https://api.socialcutter.theboomer.dev"
SC_KEY = "sc_tu_clave"

DESTINATIONS = [
    {"platform": "instagram", "format": "post"},
    {"platform": "linkedin", "format": "post"},
]

# 1. Leer articulos con imagen
params = {
    "fields": '["name","item_name","image"]',
    "filters": '[["image","!=",""]]',
    "limit_page_length": 50,
}
items = requests.get(f"{BASE}/api/resource/Item", headers=ERP_HEADERS, params=params, timeout=60)
items.raise_for_status()

for item in items.json()["data"]:
    raw = requests.get(f"{BASE}{item['image']}", headers=ERP_HEADERS, timeout=60).content

    # 2. Procesar con SocialCutter
    sc = requests.post(
        f"{SC_URL}/api/v1/images/process/upload",
        headers={"X-API-Key": SC_KEY},
        files={"file": (f"{item['name']}.jpg", raw, "image/jpeg")},
        data={"destinations": json.dumps(DESTINATIONS)},
        timeout=60,
    )
    sc.raise_for_status()
    result = sc.json()

    for output in result["outputs"]:
        print(item["name"], output.get("platform"), output.get("format"), output.get("url"))

    # Salida 1:1 como imagen principal del articulo
    square = next(o for o in result["outputs"] if o["platform"] == "instagram")
    img = requests.get(square["url"], timeout=60)
    img.raise_for_status()

    # 3. Subir el fichero y escribir la URL
    upload = requests.post(
        f"{BASE}/api/method/upload_file",
        headers=ERP_HEADERS,
        files={"file": (f"{item['name']}-sq.jpg", img.content, "image/jpeg")},
        data={"doctype": "Item", "docname": item["name"], "is_private": 0},
        timeout=60,
    )
    upload.raise_for_status()
    file_url = upload.json()["message"]["file_url"]

    requests.put(
        f"{BASE}/api/resource/Item/{item['name']}",
        headers=ERP_HEADERS,
        json={"image": file_url},
        timeout=60,
    ).raise_for_status()
    print("Actualizado", item["name"], item["item_name"])
```

## Errores típicos

| Situación | Causa habitual |
|---|---|
| `401` de ERPNext | Token mal formado o caducado; revisa `Authorization: token key:secret` |
| `403` de ERPNext | El usuario del token no tiene permiso de escritura sobre `Item` |
| `417` al escribir | El valor de `image` no es una ruta de fichero válida |
| `image` vacío | El artículo no tiene imagen asignada |
| `413` de SocialCutter | La imagen original supera 5 MB |

Si `upload_file` devuelve una estructura distinta, imprime la respuesta completa con `print(upload.json())`: el nombre exacto de la clave cambia entre versiones de Frappe.

## Coste

- 1 uso por destino (plataforma y formato) por petición.
- Los destinos repetidos en la misma petición no se cobran dos veces.
- Los procesamientos fallidos se devuelven.

Procesar 100 artículos para dos destinos son 200 usos. Para un catálogo grande, usa `POST /api/v1/images/batch` o reparte el trabajo por lotes.

## Siguientes pasos

- Guía de la API con curl: [Procesa imágenes con la API desde la terminal](/guias/curl/)
- Guía de Python: [Automatiza SocialCutter con Python](/guias/python/)
- Guía de Odoo: [Normaliza imágenes de catálogo en Odoo](/guias/odoo/)
- Documentación de la API: https://docs.socialcutter.theboomer.dev