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
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:
- Abre el usuario en ERPNext y ve a Settings → API Access.
- Genera API Key y API Secret.
- 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:
| 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.doctypeydocname: a qué documento se adjunta.is_private:0o1.file_url: en lugar defile, para registrar una URL ya existente sin subir bytes.filename,folderydocfield: 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.
# 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ó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
- Guía de Python: Automatiza SocialCutter con Python
- Guía de Odoo: Normaliza imágenes de catálogo en Odoo
- Documentación de la API: https://docs.socialcutter.theboomer.dev
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.