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 `
${
imagen
? `
${
esVendido
? `
VENDIDO
`
: ""
}
`
: `
`;
}
).join("");
// ---------------------------------------------------
// PÁGINA VISUAL
// ---------------------------------------------------
return new Response(
`
1000 Antigüedades — Catálogo
${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
`,
{
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
}
);
}
};
SIN FOTO
`
}
1000 ANTIGÜEDADES
En cada pieza, una historia
1000 ANTIGÜEDADES
En cada pieza, una historia
CATEGORÍAS
TODAS
ANTIGÜEDADES
RETRO
COLECCIONES
OTROS PRODUCTOS
ORDENAR
ORDEN ALFABÉTICO
PRECIO: MENOR A MAYOR
PRECIO: MAYOR A MENOR
" ); // --------------------------------------------------- // 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( `
1000 ANTIGÜEDADES
En cada pieza, una historia
${galeria}
${esVendido ? `VENDIDO
` : ""}