Saltar al contenido principal
SocialCutter

ERP y gestión

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.

  • ERPNext
  • Frappe
  • REST API
  • doctype Item
  • catálogo
  • ecommerce
  • Python

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

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:

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:

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

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

DestinoMedidas
instagram post1080x1080
facebook post1200x630
linkedin post1200x627
twitter post1200x675
youtube thumbnail1280x720
tiktok cover1080x1920

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:

CampoTipoQué guarda
file_urlDataLa ruta, por ejemplo /files/producto-42.jpg
file_nameDataEl nombre del fichero
is_privateCheck0 público, 1 privado
attached_to_doctypeLinkEl doctype al que se adjunta (Item)
attached_to_nameDataEl documento concreto
folderLinkCarpeta de File donde vive
content_hashDataHash 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_privateCarpetaURLAcceso
0{site}/public/files/…/files/producto-42.jpgCualquiera con la URL, sin autenticación
1{site}/private/files/…/private/files/producto-42.jpgSolo 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.

# 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

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ónCausa habitual
401 de ERPNextToken mal formado o caducado; revisa Authorization: token key:secret
403 de ERPNextEl usuario del token no tiene permiso de escritura sobre Item
417 al escribirEl valor de image no es una ruta de fichero válida
image vacíoEl artículo no tiene imagen asignada
413 de SocialCutterLa 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

Preguntas frecuentes

¿Cómo me autentico contra ERPNext?

Con un token de API. Genera una clave y un secreto (api_key y api_secret) en el usuario y envíalos en la cabecera Authorization: token api_key:api_secret.

¿Qué campo guarda la imagen del artículo?

El doctype Item tiene un campo image que almacena la ruta del fichero, por ejemplo /files/mi-foto.jpg. Para descargarlo se antepone la base del sitio.

¿Cómo subo la imagen procesada?

Con POST /api/method/upload_file en multipart, pasando el fichero y opcionalmente doctype=Item y docname. La respuesta trae message.file_url, que se escribe en el campo image del artículo.

¿Qué hace exactamente is_private?

Decide en qué carpeta acaba el fichero. is_private=0 lo deja en la carpeta pública, con URL /files/…, y lo puede leer cualquiera que tenga la URL. is_private=1 lo deja en la carpeta privada, con URL /private/files/…, y solo lo lee el propietario o quien tenga permiso sobre el documento enlazado.

¿Las medidas se guardan solas?

No. ERPNext no guarda las medidas de la imagen en el artículo por defecto. Las medidas son fijas por destino; si quieres conservarlas, usa un campo personalizado o el campo description del artículo.

¿Cuánto cuesta procesar un artículo?

1 uso por destino. Procesar un artículo para Instagram post y LinkedIn post son 2 usos.