code

const REDIRECT_URI = "https://1000antiguedades-oauth.1000antiguedades.workers.dev/callback"; // ===================================================== // FUNCIÓN DE SEGURIDAD HTML // ===================================================== function escapeHtml(text) { return String(text ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } export default { async fetch(request, env) { const url = new URL(request.url); // ===================================================== // PÁGINA PRINCIPAL // ===================================================== if (url.pathname === "/") { return new Response( "1000 Antigüedades — Conexión con Mercado Libre\n\n" + "Visita /login para iniciar la autorización.\n\n" + "Catálogo: /catalogo", { headers: { "Content-Type": "text/plain; charset=utf-8" } } ); } // ===================================================== // INICIAR AUTORIZACIÓN // ===================================================== if (url.pathname === "/login") { const codeVerifier = crypto.randomUUID() + crypto.randomUUID(); const data = new TextEncoder().encode(codeVerifier); const digest = await crypto.subtle.digest( "SHA-256", data ); const codeChallenge = btoa( String.fromCharCode( ...new Uint8Array(digest) ) ) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, ""); const authUrl = new URL( "https://auth.mercadolibre.com.co/authorization" ); authUrl.searchParams.set( "response_type", "code" ); authUrl.searchParams.set( "client_id", env.ML_CLIENT_ID ); authUrl.searchParams.set( "redirect_uri", REDIRECT_URI ); authUrl.searchParams.set( "code_challenge", codeChallenge ); authUrl.searchParams.set( "code_challenge_method", "S256" ); return new Response(null, { status: 302, headers: { Location: authUrl.toString(), "Set-Cookie": `ml_code_verifier=${codeVerifier}; ` + "Path=/; Secure; HttpOnly; SameSite=Lax" } }); } // ===================================================== // CALLBACK DE MERCADO LIBRE // ===================================================== if (url.pathname === "/callback") { const code = url.searchParams.get("code"); if (!code) { return new Response( "Mercado Libre no devolvió un código de autorización.", { status: 400 } ); } // Recuperar el code_verifier const cookie = request.headers.get("Cookie") || ""; const match = cookie.match( /ml_code_verifier=([^;]+)/ ); if (!match) { return new Response( "No encontramos el code_verifier. " + "Inicia nuevamente desde /login.", { status: 400 } ); } const codeVerifier = match[1]; // =================================================== // INTERCAMBIAR CODE POR TOKENS // =================================================== const body = new URLSearchParams(); body.set( "grant_type", "authorization_code" ); body.set( "client_id", env.ML_CLIENT_ID ); body.set( "client_secret", env.ML_CLIENT_SECRET ); body.set( "code", code ); body.set( "redirect_uri", REDIRECT_URI ); body.set( "code_verifier", codeVerifier ); const response = await fetch( "https://api.mercadolibre.com/oauth/token", { method: "POST", headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" }, body } ); const result = await response.json(); // =================================================== // COMPROBAR RESPUESTA // =================================================== if (!response.ok) { return new Response( JSON.stringify( result, null, 2 ), { status: response.status, headers: { "Content-Type": "application/json" } } ); } // =================================================== // GUARDAR TOKENS EN CLOUDFLARE KV // =================================================== await env.ML_TOKENS.put( "oauth_tokens", JSON.stringify({ access_token: result.access_token, refresh_token: result.refresh_token, expires_in: result.expires_in, user_id: result.user_id, obtained_at: Date.now() }) ); // =================================================== // BORRAR COOKIE DEL CODE VERIFIER // =================================================== return new Response( "AUTORIZACIÓN COMPLETADA CORRECTAMENTE.\n\n" + "Mercado Libre devolvió los tokens.\n\n" + "Los tokens fueron guardados de forma segura en Cloudflare KV.", { headers: { "Content-Type": "text/plain; charset=utf-8", "Set-Cookie": "ml_code_verifier=; " + "Path=/; Secure; HttpOnly; " + "SameSite=Lax; Max-Age=0" } } ); } // ===================================================== // CATÁLOGO — CONSULTA DESDE D1 // ===================================================== // ===================================================== // CATÁLOGO — TIENDA VISUAL // ===================================================== // ===================================================== // SITEMAP DE PRODUCTOS // ===================================================== if (url.pathname === "/sitemap-productos.xml") { const resultado = await env.DB.prepare( `SELECT slug FROM productos WHERE estado_tienda = ? ORDER BY id` ) .bind("disponible") .all(); const productos = resultado.results || []; const urls = productos .filter(producto => producto.slug) .map(producto => ` https://www.1000antiguedades.com/producto/${encodeURIComponent(producto.slug)} `) .join("\n"); const xml = ` ${urls} `; return new Response(xml, { headers: { "Content-Type": "application/xml; charset=utf-8" } }); } if (url.pathname === "/catalogo") { // --------------------------------------------------- // OBTENER PRODUCTOS DESDE D1 // --------------------------------------------------- const q = url.searchParams.get("q") || ""; const categoria = url.searchParams.get("categoria") || ""; const orden = url.searchParams.get("orden") || ""; let ordenSQL = "id"; if (orden === "alfabetico") { ordenSQL = "titulo COLLATE NOCASE ASC"; } if (orden === "precio-menor") { ordenSQL = "CAST(precio AS REAL) ASC"; } if (orden === "precio-mayor") { ordenSQL = "CAST(precio AS REAL) DESC"; } let productosResult; if (q.trim()) { // --------------------------------------------------- // NORMALIZAR TEXTO // --------------------------------------------------- const normalizarTexto = texto => String(texto || "") .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/[-\s]+/g, ""); const textoBusqueda = normalizarTexto(q.trim()); // --------------------------------------------------- // SINÓNIMOS CONTROLADOS // --------------------------------------------------- let terminosBusqueda = [ textoBusqueda ]; if ( textoBusqueda === "cocacola" || textoBusqueda === "coke" || textoBusqueda === "gaseosa" || textoBusqueda === "postobon" ) { terminosBusqueda = [ "cocacola", "coke", "gaseosa", "postobon" ]; } // --------------------------------------------------- // OBTENER PRODUCTOS DESDE D1 // --------------------------------------------------- let consultaCategoria = ""; let parametroCategoria = ""; if (categoria) { consultaCategoria = ` AND EXISTS ( SELECT 1 FROM producto_categorias pc JOIN categorias c ON c.id = pc.categoria_id WHERE pc.producto_id = productos.id AND c.nombre = ? ) `; parametroCategoria = categoria; } productosResult = await env.DB.prepare( `SELECT id, ml_id, slug, titulo, precio, moneda, descripcion, condicion, categoria_tienda, estado_tienda FROM productos WHERE 1 = 1 ${consultaCategoria} ORDER BY ${ordenSQL}` ) .bind( ...(parametroCategoria ? [parametroCategoria] : []) ) .all(); // --------------------------------------------------- // FILTRAR CON TEXTO NORMALIZADO // --------------------------------------------------- const productosTodos = productosResult.results || []; const productosFiltrados = productosTodos.filter( producto => { const titulo = normalizarTexto( producto.titulo ); const descripcion = normalizarTexto( producto.descripcion ); const categoria = normalizarTexto( producto.categoria_tienda ); const textoProducto = `${titulo} ${descripcion} ${categoria}`; return terminosBusqueda.some( termino => textoProducto.includes(termino) ); } ); productosResult = { results: productosFiltrados }; } else { // --------------------------------------------------- // SIN BÚSQUEDA → FILTRAR POR CATEGORÍA SI EXISTE // --------------------------------------------------- let consultaCategoria = ""; let parametroCategoria = ""; if (categoria) { consultaCategoria = ` WHERE EXISTS ( SELECT 1 FROM producto_categorias pc JOIN categorias c ON c.id = pc.categoria_id WHERE pc.producto_id = productos.id AND c.nombre = ? ) `; parametroCategoria = categoria; } productosResult = await env.DB.prepare( `SELECT id, ml_id, slug, titulo, precio, moneda, descripcion, condicion, categoria_tienda, estado_tienda FROM productos ${consultaCategoria} ORDER BY ${ordenSQL}` ) .bind( ...(parametroCategoria ? [parametroCategoria] : []) ) .all(); } const productos = productosResult.results || []; // --------------------------------------------------- // OBTENER FOTO PRINCIPAL // --------------------------------------------------- const productosConFotos = await Promise.all( productos.map( async producto => { const fotoResult = await env.DB.prepare( `SELECT url FROM producto_fotos WHERE producto_id = ? ORDER BY es_principal DESC, orden ASC LIMIT 1` ) .bind(producto.id) .first(); return { ...producto, foto_principal: fotoResult ? fotoResult.url : null }; } ) ); // --------------------------------------------------- // CONSTRUIR TARJETAS // --------------------------------------------------- const tarjetas = productosConFotos.map( producto => { const imagen = producto.foto_principal ? `/foto?archivo=${encodeURIComponent( producto.foto_principal )}` : ""; const precio = Number( producto.precio || 0 ).toLocaleString("es-CO"); const esVendido = producto.estado_tienda === "vendido"; return ` `; } ).join(""); // --------------------------------------------------- // PÁGINA VISUAL // --------------------------------------------------- return new Response( ` 1000 Antigüedades — Catálogo

En cada pieza, una historia

${tarjetas}

`, { headers: { "Content-Type": "text/html; charset=utf-8" } } ); } // ===================================================== // FICHA INDIVIDUAL DEL PRODUCTO // ===================================================== if (url.pathname.startsWith("/producto/")) { // --------------------------------------------------- // OBTENER SLUG // --------------------------------------------------- const slug = decodeURIComponent( url.pathname.substring( "/producto/".length ) ); if (!slug) { return new Response( "Producto no especificado.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" } } ); } // --------------------------------------------------- // BUSCAR PRODUCTO EN D1 // --------------------------------------------------- const producto = await env.DB.prepare( `SELECT id, ml_id, slug, titulo, precio, moneda, descripcion, condicion, categoria_tienda, estado_tienda FROM productos WHERE slug = ? LIMIT 1` ) .bind(slug) .first(); if (!producto) { return new Response( "Producto no encontrado.", { status: 404, headers: { "Content-Type": "text/plain; charset=utf-8" } } ); } // --------------------------------------------------- // OBTENER TODAS LAS FOTOGRAFÍAS // --------------------------------------------------- const fotosResult = await env.DB.prepare( `SELECT url, orden, es_principal FROM producto_fotos WHERE producto_id = ? ORDER BY es_principal DESC, orden ASC` ) .bind(producto.id) .all(); const fotos = fotosResult.results || []; // --------------------------------------------------- // CONSTRUIR GALERÍA // --------------------------------------------------- const galeria = fotos.map( (foto, indice) => { const imagen = `/foto?archivo=${encodeURIComponent( foto.url )}`; return ` `; } ).join(""); const esVendido = producto.estado_tienda === "vendido"; // --------------------------------------------------- // PRECIO // --------------------------------------------------- const precio = Number( producto.precio || 0 ).toLocaleString("es-CO"); // --------------------------------------------------- // DESCRIPCIÓN // --------------------------------------------------- const descripcion = escapeHtml( producto.descripcion || "" ).replace( /\n/g, "
" ); // --------------------------------------------------- // DATOS ESTRUCTURADOS — PRODUCTO // --------------------------------------------------- const datosEstructuradosProducto = { "@context": "https://schema.org", "@type": "Product", "name": producto.titulo, "description": producto.descripcion || "", "url": `https://www.1000antiguedades.com/producto/${encodeURIComponent(producto.slug)}`, "image": fotos.map(foto => `https://www.1000antiguedades.com/foto?archivo=${encodeURIComponent(foto.url)}` ), "offers": { "@type": "Offer", "url": `https://www.1000antiguedades.com/producto/${encodeURIComponent(producto.slug)}`, "priceCurrency": producto.moneda || "COP", "price": producto.precio, "availability": producto.estado_tienda === "disponible" ? "https://schema.org/InStock" : "https://schema.org/OutOfStock" } }; const jsonLdProducto = JSON.stringify(datosEstructuradosProducto); // --------------------------------------------------- // PÁGINA // --------------------------------------------------- return new Response( ` ${escapeHtml( producto.titulo )} — 1000 Antigüedades
1000 ANTIGÜEDADES
En cada pieza, una historia
${galeria}
${esVendido ? `
VENDIDO
` : ""}
`, { headers: { "Content-Type": "text/html; charset=utf-8" } } ); } // ===================================================== // INVENTARIO COMPLETO DE MERCADO LIBRE // ===================================================== if (url.pathname === "/inventario") { // Leer tokens const tokenData = await env.ML_TOKENS.get( "oauth_tokens", "json" ); if (!tokenData) { return new Response( "No hay tokens guardados. Primero autoriza Mercado Libre.", { status: 401, headers: { "Content-Type": "text/plain; charset=utf-8" } } ); } let accessToken = tokenData.access_token; const userId = tokenData.user_id; // =================================================== // RENOVAR TOKEN SI ESTÁ VENCIDO // =================================================== const obtenidoHace = Date.now() - (tokenData.obtained_at || 0); const venceEn = (tokenData.expires_in || 21600) * 1000; if (obtenidoHace >= venceEn) { const refreshBody = new URLSearchParams(); refreshBody.set( "grant_type", "refresh_token" ); refreshBody.set( "client_id", env.ML_CLIENT_ID ); refreshBody.set( "client_secret", env.ML_CLIENT_SECRET ); refreshBody.set( "refresh_token", tokenData.refresh_token ); const refreshResponse = await fetch( "https://api.mercadolibre.com/oauth/token", { method: "POST", headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" }, body: refreshBody } ); const refreshData = await refreshResponse.json(); if (!refreshResponse.ok) { return new Response( JSON.stringify( refreshData, null, 2 ), { status: refreshResponse.status, headers: { "Content-Type": "application/json" } } ); } accessToken = refreshData.access_token; await env.ML_TOKENS.put( "oauth_tokens", JSON.stringify({ access_token: refreshData.access_token, refresh_token: refreshData.refresh_token || tokenData.refresh_token, expires_in: refreshData.expires_in, user_id: refreshData.user_id || tokenData.user_id, obtained_at: Date.now() }) ); } // =================================================== // RECORRER TODAS LAS PUBLICACIONES // =================================================== const publicaciones = []; let offset = 0; const limit = 50; let total = null; while (true) { const searchUrl = new URL( `https://api.mercadolibre.com/users/${userId}/items/search` ); searchUrl.searchParams.set( "limit", String(limit) ); searchUrl.searchParams.set( "offset", String(offset) ); const searchResponse = await fetch( searchUrl.toString(), { headers: { "Authorization": `Bearer ${accessToken}` } } ); const searchData = await searchResponse.json(); if (!searchResponse.ok) { return new Response( JSON.stringify( searchData, null, 2 ), { status: searchResponse.status, headers: { "Content-Type": "application/json" } } ); } // Guardar total informado por Mercado Libre if (total === null) { total = searchData.paging?.total || 0; } const resultados = searchData.results || []; publicaciones.push( ...resultados ); // ================================================= // COMPROBAR SI YA TERMINAMOS // ================================================= if ( resultados.length === 0 || publicaciones.length >= total ) { break; } offset += limit; } // =================================================== // DEVOLVER INVENTARIO // =================================================== return new Response( JSON.stringify( { usuario: userId, total_reportado_por_mercado_libre: total, publicaciones_encontradas: publicaciones.length, ids: publicaciones }, null, 2 ), { headers: { "Content-Type": "application/json; charset=utf-8" } } ); } // ===================================================== // PRUEBA DE IMPORTACIÓN — 5 PRODUCTOS // ===================================================== if (url.pathname === "/prueba-importacion") { const tokenData = await env.ML_TOKENS.get( "oauth_tokens", "json" ); if (!tokenData) { return new Response( "No hay tokens guardados.", { status: 401 } ); } const accessToken = tokenData.access_token; const userId = tokenData.user_id; // =================================================== // OBTENER LOS PRIMEROS 5 IDS // =================================================== const searchUrl = new URL( `https://api.mercadolibre.com/users/${userId}/items/search` ); searchUrl.searchParams.set( "limit", "5" ); searchUrl.searchParams.set( "offset", "0" ); const searchResponse = await fetch( searchUrl.toString(), { headers: { "Authorization": `Bearer ${accessToken}` } } ); const searchData = await searchResponse.json(); if (!searchResponse.ok) { return new Response( JSON.stringify( searchData, null, 2 ), { status: searchResponse.status, headers: { "Content-Type": "application/json" } } ); } const ids = searchData.results || []; // =================================================== // OBTENER DATOS DE LOS 5 PRODUCTOS // =================================================== const productos = await Promise.all( ids.map(async id => { // ----------------------------------------------- // DATOS DEL PRODUCTO // ----------------------------------------------- const itemResponse = await fetch( `https://api.mercadolibre.com/items/${id}`, { headers: { "Authorization": `Bearer ${accessToken}` } } ); const item = await itemResponse.json(); // ----------------------------------------------- // DESCRIPCIÓN // ----------------------------------------------- let descripcion = ""; const descripcionResponse = await fetch( `https://api.mercadolibre.com/items/${id}/description`, { headers: { "Authorization": `Bearer ${accessToken}` } } ); if (descripcionResponse.ok) { const descripcionData = await descripcionResponse.json(); descripcion = descripcionData.plain_text || ""; } // ----------------------------------------------- // FOTOGRAFÍAS // ----------------------------------------------- const fotos = (item.pictures || []) .map(picture => picture.secure_url ) .filter(Boolean); // ----------------------------------------------- // PRODUCTO // ----------------------------------------------- return { id: item.id, titulo: item.title, precio: item.price, moneda: item.currency_id, estado_mercadolibre: item.status, condicion: item.condition, categoria_mercadolibre: item.category_id, fotos: fotos, cantidad_fotos: fotos.length, descripcion: descripcion, origen: "mercadolibre" }; }) ); // =================================================== // RESULTADO // =================================================== return new Response( JSON.stringify( { total_prueba: productos.length, productos: productos }, null, 2 ), { headers: { "Content-Type": "application/json; charset=utf-8" } } ); } // ===================================================== // IMPORTACIÓN CONTROLADA — 5 PRODUCTOS // REANUDABLE — NO DUPLICA FOTOS // ===================================================== if (url.pathname === "/importar-5") { // --------------------------------------------------- // LEER TOKENS // --------------------------------------------------- const tokenData = await env.ML_TOKENS.get( "oauth_tokens", "json" ); if (!tokenData) { return new Response( "No hay tokens guardados. Primero autoriza Mercado Libre.", { status: 401 } ); } let accessToken = tokenData.access_token; const userId = tokenData.user_id; // --------------------------------------------------- // RENOVAR TOKEN SI ESTÁ VENCIDO // --------------------------------------------------- const obtenidoHace = Date.now() - (tokenData.obtained_at || 0); const venceEn = (tokenData.expires_in || 21600) * 1000; if (obtenidoHace >= venceEn) { const refreshBody = new URLSearchParams(); refreshBody.set( "grant_type", "refresh_token" ); refreshBody.set( "client_id", env.ML_CLIENT_ID ); refreshBody.set( "client_secret", env.ML_CLIENT_SECRET ); refreshBody.set( "refresh_token", tokenData.refresh_token ); const refreshResponse = await fetch( "https://api.mercadolibre.com/oauth/token", { method: "POST", headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" }, body: refreshBody } ); const refreshData = await refreshResponse.json(); if (!refreshResponse.ok) { return new Response( JSON.stringify( refreshData, null, 2 ), { status: refreshResponse.status, headers: { "Content-Type": "application/json" } } ); } accessToken = refreshData.access_token; await env.ML_TOKENS.put( "oauth_tokens", JSON.stringify({ access_token: refreshData.access_token, refresh_token: refreshData.refresh_token || tokenData.refresh_token, expires_in: refreshData.expires_in, user_id: refreshData.user_id || tokenData.user_id, obtained_at: Date.now() }) ); } // --------------------------------------------------- // OBTENER 5 PRODUCTOS DE MERCADO LIBRE // --------------------------------------------------- const searchUrl = new URL( `https://api.mercadolibre.com/users/${userId}/items/search` ); searchUrl.searchParams.set( "limit", "20" ); searchUrl.searchParams.set( "offset", "0" ); const searchResponse = await fetch( searchUrl.toString(), { headers: { "Authorization": `Bearer ${accessToken}` } } ); const searchData = await searchResponse.json(); if (!searchResponse.ok) { return new Response( JSON.stringify( searchData, null, 2 ), { status: searchResponse.status, headers: { "Content-Type": "application/json" } } ); } const ids = searchData.results || []; // --------------------------------------------------- // CONTROL DE DESCARGA // MÁXIMO 20 FOTOS NUEVAS POR EJECUCIÓN // --------------------------------------------------- const MAX_FOTOS_POR_EJECUCION = 20; let fotosNuevasTotales = 0; let bytesNuevosTotales = 0; const resultados = []; // --------------------------------------------------- // PROCESAR PRODUCTOS UNO POR UNO // --------------------------------------------------- for (const mlId of ids) { try { // ----------------------------------------------- // COMPROBAR SI YA EXISTE EN D1 // ----------------------------------------------- const existente = await env.DB.prepare( "SELECT id, titulo FROM productos WHERE ml_id = ?" ) .bind(mlId) .first(); let productoId = null; let productoNuevo = false; // ----------------------------------------------- // SI YA EXISTE // ----------------------------------------------- if (existente) { productoId = existente.id; } // ----------------------------------------------- // OBTENER DATOS ACTUALES DE MERCADO LIBRE // ----------------------------------------------- const itemResponse = await fetch( `https://api.mercadolibre.com/items/${mlId}`, { headers: { "Authorization": `Bearer ${accessToken}` } } ); const item = await itemResponse.json(); if (!itemResponse.ok) { resultados.push({ ml_id: mlId, estado: "error_producto", detalle: item }); continue; } // ----------------------------------------------- // DESCRIPCIÓN // ----------------------------------------------- let descripcion = ""; try { const descripcionResponse = await fetch( `https://api.mercadolibre.com/items/${mlId}/description`, { headers: { "Authorization": `Bearer ${accessToken}` } } ); if (descripcionResponse.ok) { const descripcionData = await descripcionResponse.json(); descripcion = descripcionData.plain_text || ""; } } catch (error) { descripcion = ""; } // ----------------------------------------------- // SI EL PRODUCTO NO EXISTE, CREARLO // ----------------------------------------------- if (!existente) { const slugBase = (item.title || mlId) .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); const slug = `${slugBase}-${mlId.toLowerCase()}`; const insertProducto = await env.DB.prepare( `INSERT INTO productos ( ml_id, slug, titulo, precio, moneda, descripcion, condicion, categoria_ml, categoria_tienda, estado_ml, estado_tienda, origen ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( item.id, slug, item.title || "", item.price || null, item.currency_id || "COP", descripcion, item.condition || null, item.category_id || null, "Antigüedades", item.status || null, "disponible", "mercadolibre" ) .run(); productoId = insertProducto.meta.last_row_id; productoNuevo = true; } // ----------------------------------------------- // FOTOGRAFÍAS DE MERCADO LIBRE // SOLO pictures[] // NO VIDEOS // ----------------------------------------------- const fotos = (item.pictures || []) .map( picture => picture.secure_url ) .filter(Boolean); // ----------------------------------------------- // CONSULTAR FOTOS YA REGISTRADAS EN D1 // ----------------------------------------------- const fotosExistentesResult = await env.DB.prepare( `SELECT orden, url FROM producto_fotos WHERE producto_id = ? ORDER BY orden` ) .bind(productoId) .all(); const fotosExistentes = fotosExistentesResult.results || []; const ordenesExistentes = new Set( fotosExistentes.map( foto => Number(foto.orden) ) ); const cantidadAntes = fotosExistentes.length; let fotosGuardadasEnEstaEjecucion = 0; let bytesProducto = 0; // ----------------------------------------------- // DESCARGAR SOLO LAS FOTOS QUE FALTAN // ----------------------------------------------- for ( let i = 0; i < fotos.length; i++ ) { // Si ya tenemos demasiadas fotos en esta ejecución, // dejamos que la siguiente ejecución continúe. if ( fotosNuevasTotales >= MAX_FOTOS_POR_EJECUCION ) { break; } // --------------------------------------------- // SI ESTA FOTO YA EXISTE, NO LA DESCARGAMOS // --------------------------------------------- if ( ordenesExistentes.has(i) ) { continue; } const fotoUrl = fotos[i]; try { const fotoResponse = await fetch(fotoUrl); if (!fotoResponse.ok) { continue; } const contenido = await fotoResponse.arrayBuffer(); const contentType = fotoResponse.headers.get( "content-type" ) || "image/jpeg"; const extension = contentType.includes("png") ? "png" : contentType.includes("webp") ? "webp" : "jpg"; const nombreObjeto = `productos/${mlId}/foto-${i + 1}.${extension}`; // ------------------------------------------- // GUARDAR EN R2 // ------------------------------------------- await env.FOTOS.put( nombreObjeto, contenido, { httpMetadata: { contentType: contentType } } ); // ------------------------------------------- // REGISTRAR EN D1 // ------------------------------------------- await env.DB.prepare( `INSERT INTO producto_fotos ( producto_id, url, orden, es_principal ) VALUES (?, ?, ?, ?)` ) .bind( productoId, nombreObjeto, i, i === 0 ? 1 : 0 ) .run(); // ------------------------------------------- // CONTADORES // ------------------------------------------- fotosGuardadasEnEstaEjecucion++; fotosNuevasTotales++; bytesProducto += contenido.byteLength; bytesNuevosTotales += contenido.byteLength; } catch (error) { // Si una foto falla, // continuamos con las demás. } } // ----------------------------------------------- // CANTIDAD FINAL DE FOTOS // ----------------------------------------------- const fotosFinales = cantidadAntes + fotosGuardadasEnEstaEjecucion; // ----------------------------------------------- // DETERMINAR ESTADO // ----------------------------------------------- let estado; if (productoNuevo) { estado = fotosFinales >= fotos.length ? "importado_completo" : "importado_parcial"; } else { if ( fotosFinales >= fotos.length ) { estado = fotosGuardadasEnEstaEjecucion > 0 ? "fotos_completadas" : "ya_existia_completo"; } else { estado = fotosGuardadasEnEstaEjecucion > 0 ? "fotos_completadas_parcial" : "ya_existia_pendiente"; } } // ----------------------------------------------- // RESULTADO DEL PRODUCTO // ----------------------------------------------- resultados.push({ ml_id: mlId, estado: estado, producto_id: productoId, titulo: item.title, fotos_encontradas: fotos.length, fotos_que_ya_existian: cantidadAntes, fotos_nuevas: fotosGuardadasEnEstaEjecucion, fotos_totales: fotosFinales, fotos_pendientes: Math.max( fotos.length - fotosFinales, 0 ), espacio_bytes: bytesProducto, espacio_MB: ( bytesProducto / 1024 / 1024 ).toFixed(2) }); } catch (error) { resultados.push({ ml_id: mlId, estado: "error", mensaje: error.message }); } } // --------------------------------------------------- // RESUMEN // --------------------------------------------------- const espacioTotal = bytesNuevosTotales; const pendientes = resultados.filter( r => (r.fotos_pendientes || 0) > 0 ).length; // --------------------------------------------------- // RESPUESTA // --------------------------------------------------- return new Response( JSON.stringify( { limite_prueba: 5, maximo_fotos_por_ejecucion: MAX_FOTOS_POR_EJECUCION, encontrados: ids.length, fotos_nuevas_en_esta_ejecucion: fotosNuevasTotales, productos_con_fotos_pendientes: pendientes, espacio_total_bytes: espacioTotal, espacio_total_MB: ( espacioTotal / 1024 / 1024 ).toFixed(2), resultados: resultados }, null, 2 ), { headers: { "Content-Type": "application/json; charset=utf-8" } } ); } // ===================================================== // IMPORTACIÓN MASIVA DE PRODUCTOS — REANUDABLE // NO DESCARGA FOTOS // ===================================================== if (url.pathname === "/importar-productos") { const tokenData = await env.ML_TOKENS.get( "oauth_tokens", "json" ); if (!tokenData) { return new Response( "No hay tokens guardados.", { status: 401 } ); } let accessToken = tokenData.access_token; const userId = tokenData.user_id; // --------------------------------------------------- // RENOVAR TOKEN SI ESTÁ VENCIDO // --------------------------------------------------- const obtenidoHace = Date.now() - (tokenData.obtained_at || 0); const venceEn = (tokenData.expires_in || 21600) * 1000; if (obtenidoHace >= venceEn) { const refreshBody = new URLSearchParams(); refreshBody.set( "grant_type", "refresh_token" ); refreshBody.set( "client_id", env.ML_CLIENT_ID ); refreshBody.set( "client_secret", env.ML_CLIENT_SECRET ); refreshBody.set( "refresh_token", tokenData.refresh_token ); const refreshResponse = await fetch( "https://api.mercadolibre.com/oauth/token", { method: "POST", headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" }, body: refreshBody } ); const refreshData = await refreshResponse.json(); if (!refreshResponse.ok) { return new Response( JSON.stringify( refreshData, null, 2 ), { status: refreshResponse.status, headers: { "Content-Type": "application/json" } } ); } accessToken = refreshData.access_token; await env.ML_TOKENS.put( "oauth_tokens", JSON.stringify({ access_token: refreshData.access_token, refresh_token: refreshData.refresh_token || tokenData.refresh_token, expires_in: refreshData.expires_in, user_id: refreshData.user_id || tokenData.user_id, obtained_at: Date.now() }) ); } // --------------------------------------------------- // LOTE // --------------------------------------------------- const urlInterna = new URL(request.url); const offset = parseInt( urlInterna.searchParams.get( "offset" ) || "0", 10 ); const limite = parseInt( urlInterna.searchParams.get( "limite" ) || "20", 10 ); // --------------------------------------------------- // OBTENER PRODUCTOS DE MERCADO LIBRE // --------------------------------------------------- const searchUrl = new URL( `https://api.mercadolibre.com/users/${userId}/items/search` ); searchUrl.searchParams.set( "limit", String(limite) ); searchUrl.searchParams.set( "offset", String(offset) ); const searchResponse = await fetch( searchUrl.toString(), { headers: { "Authorization": `Bearer ${accessToken}` } } ); const searchData = await searchResponse.json(); if (!searchResponse.ok) { return new Response( JSON.stringify( searchData, null, 2 ), { status: searchResponse.status, headers: { "Content-Type": "application/json" } } ); } const ids = searchData.results || []; // --------------------------------------------------- // PROCESAR PRODUCTOS // --------------------------------------------------- const resultados = []; for (const mlId of ids) { try { // ----------------------------------------------- // COMPROBAR D1 // ----------------------------------------------- const existente = await env.DB.prepare( "SELECT id, titulo FROM productos WHERE ml_id = ?" ) .bind(mlId) .first(); if (existente) { resultados.push({ ml_id: mlId, estado: "ya_existia", producto_id: existente.id, titulo: existente.titulo }); continue; } // ----------------------------------------------- // OBTENER PRODUCTO // ----------------------------------------------- const itemResponse = await fetch( `https://api.mercadolibre.com/items/${mlId}`, { headers: { "Authorization": `Bearer ${accessToken}` } } ); const item = await itemResponse.json(); if (!itemResponse.ok) { resultados.push({ ml_id: mlId, estado: "error_producto", detalle: item }); continue; } // ----------------------------------------------- // DESCRIPCIÓN // ----------------------------------------------- let descripcion = ""; try { const descripcionResponse = await fetch( `https://api.mercadolibre.com/items/${mlId}/description`, { headers: { "Authorization": `Bearer ${accessToken}` } } ); if (descripcionResponse.ok) { const descripcionData = await descripcionResponse.json(); descripcion = descripcionData.plain_text || ""; } } catch (error) { descripcion = ""; } // ----------------------------------------------- // SLUG // ----------------------------------------------- const slugBase = (item.title || mlId) .toLowerCase() .normalize("NFD") .replace( /[\u0300-\u036f]/g, "" ) .replace( /[^a-z0-9]+/g, "-" ) .replace( /^-+|-+$/g, ""); const slug = `${slugBase}-${mlId.toLowerCase()}`; // ----------------------------------------------- // INSERTAR EN D1 // ----------------------------------------------- const insertProducto = await env.DB.prepare( `INSERT INTO productos ( ml_id, slug, titulo, precio, moneda, descripcion, condicion, categoria_ml, categoria_tienda, estado_ml, estado_tienda, origen ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( item.id, slug, item.title || "", item.price || null, item.currency_id || "COP", descripcion, item.condition || null, item.category_id || null, "Antigüedades", item.status || null, "disponible", "mercadolibre" ) .run(); const productoId = insertProducto.meta.last_row_id; // ----------------------------------------------- // RESULTADO // ----------------------------------------------- resultados.push({ ml_id: mlId, estado: "importado", producto_id: productoId, titulo: item.title }); } catch (error) { resultados.push({ ml_id: mlId, estado: "error", mensaje: error.message }); } } // --------------------------------------------------- // RESPUESTA // --------------------------------------------------- return new Response( JSON.stringify( { offset: offset, limite: limite, encontrados: ids.length, siguiente_offset: offset + ids.length, total_mercado_libre: searchData.paging ? searchData.paging.total : null, resultados: resultados }, null, 2 ), { headers: { "Content-Type": "application/json; charset=utf-8" } } ); } // ===================================================== // DIAGNÓSTICO — ESTADO DE FOTOGRAFÍAS // ===================================================== if (url.pathname === "/diagnostico-fotos") { // --------------------------------------------------- // TOTAL DE PRODUCTOS // --------------------------------------------------- const totalProductosResult = await env.DB.prepare( "SELECT COUNT(*) AS total FROM productos" ).first(); const totalProductos = Number(totalProductosResult?.total || 0); // --------------------------------------------------- // TOTAL DE FOTOGRAFÍAS REGISTRADAS EN D1 // --------------------------------------------------- const totalFotosResult = await env.DB.prepare( "SELECT COUNT(*) AS total FROM producto_fotos" ).first(); const totalFotos = Number(totalFotosResult?.total || 0); // --------------------------------------------------- // PRODUCTOS CON AL MENOS UNA FOTO // --------------------------------------------------- const productosConFotosResult = await env.DB.prepare( `SELECT COUNT(DISTINCT producto_id) AS total FROM producto_fotos` ).first(); const productosConFotos = Number(productosConFotosResult?.total || 0); // --------------------------------------------------- // PRODUCTOS SIN NINGUNA FOTO // --------------------------------------------------- const productosSinFotos = Math.max( totalProductos - productosConFotos, 0 ); // --------------------------------------------------- // PROMEDIO DE FOTOS POR PRODUCTO CON FOTOS // --------------------------------------------------- const promedioFotos = productosConFotos > 0 ? ( totalFotos / productosConFotos ).toFixed(2) : "0.00"; // --------------------------------------------------- // RESPUESTA // --------------------------------------------------- return new Response( JSON.stringify( { total_productos: totalProductos, total_fotos_en_D1: totalFotos, productos_con_fotos: productosConFotos, productos_sin_fotos: productosSinFotos, promedio_fotos_por_producto: promedioFotos }, null, 2 ), { headers: { "Content-Type": "application/json; charset=utf-8" } } ); } // ===================================================== // IMPORTACIÓN DE FOTOS — 10 PRODUCTOS POR LOTE // REANUDABLE — NO DUPLICA FOTOS // ===================================================== if (url.pathname === "/importar-fotos") { // --------------------------------------------------- // LEER TOKENS // --------------------------------------------------- const tokenData = await env.ML_TOKENS.get( "oauth_tokens", "json" ); if (!tokenData) { return new Response( "No hay tokens guardados.", { status: 401 } ); } let accessToken = tokenData.access_token; const userId = tokenData.user_id; // --------------------------------------------------- // RENOVAR TOKEN SI ESTÁ VENCIDO // --------------------------------------------------- const obtenidoHace = Date.now() - (tokenData.obtained_at || 0); const venceEn = (tokenData.expires_in || 21600) * 1000; if (obtenidoHace >= venceEn) { const refreshBody = new URLSearchParams(); refreshBody.set( "grant_type", "refresh_token" ); refreshBody.set( "client_id", env.ML_CLIENT_ID ); refreshBody.set( "client_secret", env.ML_CLIENT_SECRET ); refreshBody.set( "refresh_token", tokenData.refresh_token ); const refreshResponse = await fetch( "https://api.mercadolibre.com/oauth/token", { method: "POST", headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" }, body: refreshBody } ); const refreshData = await refreshResponse.json(); if (!refreshResponse.ok) { return new Response( JSON.stringify( refreshData, null, 2 ), { status: refreshResponse.status, headers: { "Content-Type": "application/json" } } ); } accessToken = refreshData.access_token; await env.ML_TOKENS.put( "oauth_tokens", JSON.stringify({ access_token: refreshData.access_token, refresh_token: refreshData.refresh_token || tokenData.refresh_token, expires_in: refreshData.expires_in, user_id: refreshData.user_id || tokenData.user_id, obtained_at: Date.now() }) ); } // --------------------------------------------------- // LOTE // --------------------------------------------------- const urlInterna = new URL(request.url); const offset = parseInt( urlInterna.searchParams.get( "offset" ) || "0", 10 ); const limite = parseInt( urlInterna.searchParams.get( "limite" ) || "10", 10 ); // --------------------------------------------------- // LÍMITE DE SEGURIDAD // --------------------------------------------------- const MAX_FOTOS = 20; let fotosNuevasTotales = 0; let bytesNuevosTotales = 0; const resultados = []; // --------------------------------------------------- // OBTENER PRODUCTOS DE D1 // --------------------------------------------------- const productosResult = await env.DB.prepare( `SELECT id, ml_id, titulo FROM productos ORDER BY id LIMIT ? OFFSET ?` ) .bind( limite, offset ) .all(); const productos = productosResult.results || []; // --------------------------------------------------- // TOTAL DE PRODUCTOS // --------------------------------------------------- const totalResult = await env.DB.prepare( "SELECT COUNT(*) AS total FROM productos" ).first(); const totalProductos = Number( totalResult?.total || 0 ); // --------------------------------------------------- // PROCESAR PRODUCTOS // --------------------------------------------------- for (const producto of productos) { try { // ----------------------------------------------- // OBTENER PRODUCTO DE MERCADO LIBRE // ----------------------------------------------- const itemResponse = await fetch( `https://api.mercadolibre.com/items/${producto.ml_id}`, { headers: { "Authorization": `Bearer ${accessToken}` } } ); const item = await itemResponse.json(); if (!itemResponse.ok) { resultados.push({ ml_id: producto.ml_id, producto_id: producto.id, estado: "error_producto", detalle: item }); continue; } // ----------------------------------------------- // FOTOGRAFÍAS // ----------------------------------------------- const fotos = (item.pictures || []) .map( picture => picture.secure_url ) .filter(Boolean); // ----------------------------------------------- // FOTOS YA REGISTRADAS // ----------------------------------------------- const fotosExistentesResult = await env.DB.prepare( `SELECT orden, url FROM producto_fotos WHERE producto_id = ? ORDER BY orden` ) .bind( producto.id ) .all(); const fotosExistentes = fotosExistentesResult.results || []; const ordenesExistentes = new Set( fotosExistentes.map( foto => Number(foto.orden) ) ); const cantidadAntes = fotosExistentes.length; let fotosNuevasProducto = 0; let bytesProducto = 0; // ----------------------------------------------- // DESCARGAR FOTOS FALTANTES // ----------------------------------------------- for ( let i = 0; i < fotos.length; i++ ) { if ( fotosNuevasTotales >= MAX_FOTOS ) { break; } // --------------------------------------------- // YA EXISTE // --------------------------------------------- if ( ordenesExistentes.has(i) ) { continue; } const fotoUrl = fotos[i]; try { const fotoResponse = await fetch( fotoUrl ); if (!fotoResponse.ok) { continue; } const contenido = await fotoResponse.arrayBuffer(); const contentType = fotoResponse.headers.get( "content-type" ) || "image/jpeg"; const extension = contentType.includes("png") ? "png" : contentType.includes("webp") ? "webp" : "jpg"; const nombreObjeto = `productos/${producto.ml_id}/foto-${i + 1}.${extension}`; // ----------------------------------------- // GUARDAR EN R2 // ----------------------------------------- await env.FOTOS.put( nombreObjeto, contenido, { httpMetadata: { contentType: contentType } } ); // ----------------------------------------- // REGISTRAR EN D1 // ----------------------------------------- await env.DB.prepare( `INSERT INTO producto_fotos ( producto_id, url, orden, es_principal ) VALUES (?, ?, ?, ?)` ) .bind( producto.id, nombreObjeto, i, i === 0 ? 1 : 0 ) .run(); // ----------------------------------------- // CONTADORES // ----------------------------------------- fotosNuevasProducto++; fotosNuevasTotales++; bytesProducto += contenido.byteLength; bytesNuevosTotales += contenido.byteLength; } catch (error) { // Si una foto falla, // continuamos con las demás. } } // ----------------------------------------------- // TOTAL FINAL // ----------------------------------------------- const fotosFinales = cantidadAntes + fotosNuevasProducto; // ----------------------------------------------- // ESTADO // ----------------------------------------------- let estado; if ( fotosFinales >= fotos.length ) { estado = fotosNuevasProducto > 0 ? "fotos_completadas" : "ya_existia_completo"; } else { estado = fotosNuevasProducto > 0 ? "fotos_parcial" : "sin_fotos"; } // ----------------------------------------------- // RESULTADO // ----------------------------------------------- resultados.push({ ml_id: producto.ml_id, producto_id: producto.id, estado: estado, titulo: producto.titulo, fotos_encontradas: fotos.length, fotos_que_ya_existian: cantidadAntes, fotos_nuevas: fotosNuevasProducto, fotos_totales: fotosFinales, fotos_pendientes: Math.max( fotos.length - fotosFinales, 0 ), espacio_bytes: bytesProducto, espacio_MB: ( bytesProducto / 1024 / 1024 ).toFixed(2) }); } catch (error) { resultados.push({ ml_id: producto.ml_id, producto_id: producto.id, estado: "error", mensaje: error.message }); } } // --------------------------------------------------- // SIGUIENTE OFFSET // --------------------------------------------------- // --------------------------------------------------- // SIGUIENTE OFFSET // --------------------------------------------------- const siguienteOffset = offset + productos.length; // --------------------------------------------------- // RESPUESTA // --------------------------------------------------- return new Response( JSON.stringify( { offset: offset, limite: limite, encontrados: productos.length, siguiente_offset: siguienteOffset, total_productos: totalProductos, maximo_fotos_por_ejecucion: MAX_FOTOS, fotos_nuevas_en_esta_ejecucion: fotosNuevasTotales, espacio_total_bytes: bytesNuevosTotales, espacio_total_MB: ( bytesNuevosTotales / 1024 / 1024 ).toFixed(2), resultados: resultados }, null, 2 ), { headers: { "Content-Type": "application/json; charset=utf-8" } } ); } // ===================================================== // SERVIR FOTOGRAFÍAS DESDE R2 // ===================================================== if (url.pathname === "/foto") { const archivo = url.searchParams.get("archivo"); if (!archivo) { return new Response( "Falta el parámetro archivo.", { status: 400 } ); } const objeto = await env.FOTOS.get(archivo); if (!objeto) { return new Response( "Fotografía no encontrada.", { status: 404 } ); } return new Response( objeto.body, { headers: { "Content-Type": objeto.httpMetadata?.contentType || "image/jpeg", "Cache-Control": "public, max-age=31536000" } } ); } return new Response( "Ruta no encontrada.", { status: 404 } ); } };