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, """)
.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
// =====================================================
if (url.pathname === "/catalogo") {
// ---------------------------------------------------
// OBTENER PRODUCTOS DESDE D1
// ---------------------------------------------------
const q =
url.searchParams.get("q") || "";
const categoria =
url.searchParams.get("categoria") || "";
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 id`
)
.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 id`
)
.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");
return `
<article class="producto-card">
<a
class="producto-imagen"
href="/producto/${producto.slug}"
>
${
imagen
? `
<img
src="${imagen}"
alt="${escapeHtml(
producto.titulo
)}"
loading="lazy"
>
`
: `
<div class="sin-foto">
SIN FOTO
</div>
`
}
</a>
<div class="producto-info">
<h2>
${escapeHtml(
producto.titulo
)}
</h2>
<div class="producto-precio">
$ ${precio}
</div>
<a
class="producto-boton"
href="/producto/${producto.slug}"
>
VER PIEZA
</a>
</div>
</article>
`;
}
).join("");
// ---------------------------------------------------
// PÁGINA VISUAL
// ---------------------------------------------------
return new Response(
`<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>
1000 Antigüedades — Catálogo
</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
background-image: url("https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEh_5NMDRQvqu-st-f3sRDOjSWvBl89yblLRDUWUIJo4ApZvvWqQ-u1Ln_VqqA4VJZfq0CFOGUC68QowcnU0SPtcYMrZJZbSlRgKW7dPqeSmFkYp_jhoUqJscpDW_Vduvjxgo5DGWyw7BOcn2vcAVe9DhcElaFRE0XYXjF_Zy3PSQfNeRO_4I1U9RMmexk8/s1248/Fondo%20de%20catalogo%20y%20Ficha%20individual.png");
background-size: 100% 100vh;
background-position: center -5px;
background-repeat: no-repeat;
background-attachment: fixed;
color: #3b2a23;
font-family:
Georgia,
"Times New Roman",
serif;
}
.enlace-inicio {
display: inline-block;
margin: 23px 0 0 60px;
color: #7a4b3f;
text-decoration: none;
font-size: 15px;
}
.enlace-inicio:hover {
text-decoration: underline;
}
.enlace-whatsapp {
color: #7a4b3f;
text-decoration: none;
font-size: 15px;
}
.enlace-whatsapp:hover {
text-decoration: underline;
}
.catalogo-contenedor {
width: 94%;
max-width: 1500px;
margin: 0 auto;
padding: 0px 0 60px;
}
.catalogo-titulo {
text-align: center;
margin-bottom: 35px;
}
.catalogo-titulo h1 {
margin: 0;
font-size: 42px;
letter-spacing: 2px;
font-weight: normal;
font-family: Cambria, serif;
}
.marca-catalogo {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 14px;
color: #3b2a23;
text-decoration: none;
cursor: pointer;
}
.marca-catalogo:hover {
color: #7a4b3f;
}
.catalogo-logo {
width: 62px;
height: 62px;
object-fit: contain;
display: block;
}
.catalogo-titulo p {
margin-top: 0px;
font-size: 18px;
font-family: inherit;
font-weight: normal;
letter-spacing: normal;
line-height: 0.2;
opacity: .8;
}
.buscador {
display: flex;
max-width: 700px;
margin: 0 auto 35px;
gap: 8px;
}
.buscador input {
flex: 1;
padding: 13px 15px;
border: 1px solid #d8c9b2;
background: #fffdf8;
color: #3b2a23;
font-family: inherit;
font-size: 15px;
}
.buscador button {
padding: 13px 22px;
background: #7a4b3f;
color: #f3ead7;
border: 1px solid #b58a43;
cursor: pointer;
font-family: inherit;
font-size: 13px;
letter-spacing: .5px;
}
.buscador button:hover {
background: #9b9270;
}
.filtros-categorias {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
margin: 0 auto 35px;
}
.filtros-categorias a {
display: inline-block;
padding: 10px 18px;
border: 1px solid #b58a43;
background: #fffdf8;
color: #7a4b3f;
text-decoration: none;
font-size: 13px;
letter-spacing: .6px;
transition:
background .2s ease,
color .2s ease;
}
.filtros-categorias a:hover {
background: #9b9270;
color: #fff;
}
.filtros-categorias a.activo {
background: #7a4b3f;
color: #f3ead7;
}
.productos-grid {
display: grid;
grid-template-columns:
repeat(3, 1fr);
gap: 28px 20px;
}
.producto-card {
background: #F3E8D4;
border: 1px solid #c3a45b;
box-shadow:
inset 0 0 0 1px #e0c77b,
0 2px 3px rgba(80, 60, 25, .22);
overflow: hidden;
transition:
transform .2s ease,
box-shadow .2s ease;
}
.producto-card:hover {
transform:
translateY(-4px);
box-shadow:
0 8px 20px
rgba(0,0,0,.12);
}
.producto-imagen {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
background: #ffffff;
overflow: hidden;
text-decoration: none;
}
.producto-imagen img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.sin-foto {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #887766;
font-size: 14px;
}
.producto-info {
padding: 14px 15px 18px;
border-top: 1px solid #b9a27a;
}
.producto-info h2 {
margin: 0 0 10px;
font-size: 18px;
line-height: 1.3;
font-weight: normal;
min-height: 47px;
}
.producto-precio {
font-size: 21px;
font-weight: bold;
margin-bottom: 15px;
}
.producto-boton {
display: inline-block;
padding: 9px 16px;
background: #7a4b3f;
color: #f3ead7;
border: 1px solid #b58a43;
text-decoration: none;
font-size: 13px;
letter-spacing: .5px;
transition:
background .2s ease;
}
.producto-boton:hover {
background: #9b9270;
color: #fff;
}
@media (max-width: 900px) {
.productos-grid {
grid-template-columns:
repeat(2, 1fr);
gap: 18px 12px;
}
.catalogo-titulo h1 {
font-size: 30px;
}
.catalogo-logo {
width: 48px;
height: 48px;
}
.marca-catalogo {
flex-direction: column;
gap: 4px;
}
.marca-catalogo .catalogo-logo {
width: 48px;
height: 48px;
}
.catalogo-titulo p {
font-size: 14px;
line-height: 1;
}
.producto-info h2 {
font-size: 16px;
}
.producto-precio {
font-size: 18px;
}
}
</style>
</head>
<body>
<div class="zona-navegacion">
<button class="menu-boton" type="button" aria-label="Abrir menú">
<span></span>
<span></span>
</button>
<nav class="menu-desplegable">
<a href="/">INICIO</a>
<a href="/catalogo">CATÁLOGO</a>
<a href="/p/archivo-fotografico-de-1000-antiguedades.html">ARCHIVO FOTOGRÁFICO</a>
<a href="https://wa.me/573183853435" target="_blank">WHATSAPP</a>
</nav>
</div>
<main class="catalogo-contenedor">
<header class="catalogo-titulo">
<h1>
<header class="catalogo-titulo">
<a
class="marca-catalogo"
href="/"
aria-label="1000 Antigüedades — Inicio"
>
<img
class="catalogo-logo"
src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjU6A7bAVT2SfsHJztJSDH6ZpGnW-JbcSlpF8Q02T5NCwf-wJaZ4qovnS4dTRQPwYz639E6jYq3m0TY3GQFVRfQwBDelnmsp2JuOqBx-2kODpLr2YQN9IdrtDV0BzoULUOl8S8SE6U-UHKHWSIypbtbo_eeYxU3HLn4dPTFnHeB5EL5n15RMkC-aic-kPg/s1254/2026-08%20LOGO%20REDONDO%20-%20sin%20fondo.png"
alt="1000 Antigüedades"
>
<span>
1000 ANTIGÜEDADES
</span>
</a>
<p>
En cada pieza, una historia
</p>
</header>
<form
class="buscador"
method="GET"
action="/catalogo"
>
<input
type="search"
name="q"
placeholder="Buscar una pieza..."
>
<button type="submit">
BUSCAR
</button>
</form>
<nav class="filtros-categorias">
<a
href="/catalogo"
class="${
!categoria
? "activo"
: ""
}"
>
TODAS
</a>
<a
href="/catalogo?categoria=Antigüedades"
class="${
categoria === "Antigüedades"
? "activo"
: ""
}"
>
ANTIGÜEDADES
</a>
<a
href="/catalogo?categoria=Retro"
class="${
categoria === "Retro"
? "activo"
: ""
}"
>
RETRO
</a>
<a
href="/catalogo?categoria=Colecciones"
class="${
categoria === "Colecciones"
? "activo"
: ""
}"
>
COLECCIONES
</a>
<a
href="/catalogo?categoria=Otros%20productos"
class="${
categoria === "Otros productos"
? "activo"
: ""
}"
>
OTROS PRODUCTOS
</a>
</nav>
<section class="productos-grid">
${tarjetas}
</section>
</main>
<script>
const menuBoton = document.querySelector('.menu-boton');
const menuDesplegable = document.querySelector('.menu-desplegable');
menuBoton.addEventListener('click', function () {
menuBoton.classList.toggle('activo');
menuDesplegable.classList.toggle('abierto');
});
</script>
</body>
</html>`,
{
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 `
<img
class="foto-producto ${
indice === 0
? "foto-activa"
: ""
}"
src="${imagen}"
alt="${escapeHtml(
producto.titulo
)} - foto ${indice + 1}"
loading="${
indice === 0
? "eager"
: "lazy"
}"
>
`;
}
).join("");
// ---------------------------------------------------
// PRECIO
// ---------------------------------------------------
const precio =
Number(
producto.precio || 0
).toLocaleString("es-CO");
// ---------------------------------------------------
// DESCRIPCIÓN
// ---------------------------------------------------
const descripcion =
escapeHtml(
producto.descripcion || ""
).replace(
/\n/g,
"<br>"
);
// ---------------------------------------------------
// PÁGINA
// ---------------------------------------------------
return new Response(
`<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>
${escapeHtml(
producto.titulo
)} — 1000 Antigüedades
</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
background-image: url("https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEh_5NMDRQvqu-st-f3sRDOjSWvBl89yblLRDUWUIJo4ApZvvWqQ-u1Ln_VqqA4VJZfq0CFOGUC68QowcnU0SPtcYMrZJZbSlRgKW7dPqeSmFkYp_jhoUqJscpDW_Vduvjxgo5DGWyw7BOcn2vcAVe9DhcElaFRE0XYXjF_Zy3PSQfNeRO_4I1U9RMmexk8/s1248/Fondo%20de%20catalogo%20y%20Ficha%20individual.png");
background-size: 100% 100vh;
background-position: center -5px;
background-repeat: no-repeat;
background-attachment: fixed;
color: #3b2a23;
font-family:
Georgia,
"Times New Roman",
serif;
}
.ficha-header {
text-align: center;
padding: 0 15px 10px;
}
.zona-inicio {
margin-left: 60px;
margin-top: 23px;
}
.zona-navegacion {
margin-left: 60px;
margin-top: 23px;
}
.menu-boton {
position: absolute;
right: 78px;
top: 42px;
width: 34px;
height: 28px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
}
.menu-boton span {
display: block;
width: 34px;
height: 4px;
background: #7a4b3f;
margin: 7px 0 7px auto;
border-radius: 4px;
box-shadow: 0 2px 2px rgba(0,0,0,.20);
transition:
transform 0.45s ease,
width 0.45s ease;
transform-origin: center;
}
.menu-boton span:nth-child(2) {
width: 27px;
}
.menu-boton.activo span:first-child {
width: 34px;
transform: translateY(5.5px) rotate(45deg);
}
.menu-boton.activo span:nth-child(2) {
width: 34px;
transform: translateY(-5.5px) rotate(-45deg);
}
.menu-desplegable {
position: absolute;
right: 78px;
top: 78px;
width: 220px;
background: rgba(243, 232, 212, 0.20);
border: 1px solid #b9a27a;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);
border-radius: 14px;
z-index: 20;
padding: 8px 0;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(-10px);
transition:
opacity 0.4s ease,
transform 0.5s ease,
visibility 0.5s ease;
}
.menu-desplegable.abierto {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(0);
}
.menu-desplegable a {
display: block;
}
.zona-navegacion {
margin-left: 60px;
margin-top: 23px;
}
.menu-boton {
position: absolute;
right: 78px;
top: 42px;
width: 34px;
height: 28px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
}
.menu-boton span {
display: block;
width: 34px;
height: 4px;
background: #7a4b3f;
margin: 7px 0 7px auto;
border-radius: 4px;
box-shadow: 0 2px 2px rgba(0,0,0,.20);
transition:
transform 0.45s ease,
width 0.45s ease;
transform-origin: center;
}
.menu-boton span:nth-child(2) {
width: 27px;
}
.menu-boton.activo span:first-child {
width: 34px;
transform: translateY(5.5px) rotate(45deg);
}
.menu-boton.activo span:nth-child(2) {
width: 34px;
transform: translateY(-5.5px) rotate(-45deg);
}
.menu-desplegable {
position: absolute;
right: 78px;
top: 78px;
width: 220px;
background: rgba(243, 232, 212, 0.20);
border: 1px solid #b9a27a;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);
border-radius: 14px;
z-index: 20;
padding: 8px 0;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(-10px);
transition:
opacity 0.4s ease,
transform 0.5s ease,
visibility 0.5s ease;
}
.menu-desplegable.abierto {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(0);
}
.menu-desplegable a {
display: block;
padding: 12px 18px;
color: #7a4b3f;
font-family: Georgia, "Times New Roman", serif;
font-size: 15px;
text-decoration: none;
opacity: 0;
transform: translateY(-8px);
transition:
opacity 0.35s ease,
transform 0.35s ease;
}
.menu-desplegable.abierto a {
opacity: 1;
transform: translateY(0);
}
.menu-desplegable.abierto a:nth-child(1) {
transition-delay: 0.10s;
}
.menu-desplegable.abierto a:nth-child(2) {
transition-delay: 0.18s;
}
.menu-desplegable.abierto a:nth-child(3) {
transition-delay: 0.26s;
}
.menu-desplegable.abierto a:nth-child(4) {
transition-delay: 0.34s;
}
.menu-desplegable a:hover {
background: #ead9bd;
}
.ficha-marca {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
font-size: 42px;
letter-spacing: 2px;
font-weight: normal;
font-family: Cambria, serif;
color: #3b2a23;
text-decoration: none;
}
.ficha-marca:hover {
color: #7a4b3f;
}
.ficha-logo {
width: 62px;
height: 62px;
object-fit: contain;
display: block;
}
.ficha-subtitulo {
font-size: 17.3px;
font-family: inherit;
font-weight: normal;
letter-spacing: normal;
opacity: .8;
}
.producto-contenedor {
width: 94%;
max-width: 1400px;
margin: 0 auto;
padding: 25px 0 60px;
}
.volver {
display: inline-block;
margin-bottom: 0px;
color: #7a4b3f;
text-decoration: none;
font-size: 15px;
}
.volver:hover {
text-decoration: underline;
}
.producto-layout {
display: flex;
flex-direction: column;
align-items: center;
gap: 35px;
}
.galeria {
display: block;
position: relative;
}
.galeria {
width: 100%;
max-width: 370px;
margin: 0 auto;
}
.carrusel-fotos {
position: relative;
}
.flecha-carrusel {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
}
.flecha-anterior {
left: 10%;
}
.flecha-siguiente {
right: 10%;
}
.galeria {
position: relative;
overflow: visible;
}
.indicadores-carrusel {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.indicador-carrusel {
width: 11px;
height: 11px;
padding: 0;
margin: 0;
border-radius: 50%;
border: 1px solid #8b745c;
background: radial-gradient(
circle at 35% 30%,
#f0dfc5 0%,
#c6aa83 35%,
#8b6f50 70%,
#5f4934 100%
);
box-shadow:
inset 1px 1px 2px rgba(255,255,255,.65),
inset -1px -1px 2px rgba(60,40,20,.55),
0 1px 2px rgba(0,0,0,.35);
cursor: pointer;
transition:
transform .15s ease,
box-shadow .15s ease;
}
.indicador-carrusel:hover {
transform: scale(1.25);
}
.indicador-carrusel.activo {
width: 13px;
height: 13px;
border-color: #4a3626;
background: radial-gradient(
circle at 35% 30%,
#b99a72 0%,
#806143 35%,
#513a27 70%,
#302116 100%
);
box-shadow:
inset 1px 1px 2px rgba(255,255,255,.45),
inset -2px -2px 3px rgba(25,15,8,.75),
0 1px 3px rgba(0,0,0,.5);
}
.carrusel-fotos {
position: relative;
}
.flecha-carrusel {
position: absolute;
top: 73%;
filter: drop-shadow(2px 3px 3px rgba(60, 60, 60, .45));
transform: translateY(-50%);
z-index: 10;
width: 75px;
height: 75px;
padding: 0;
margin: 0;
border: none;
background-color: transparent;
background-repeat: no-repeat;
background-size: 100% 100%;
background-position: center;
cursor: pointer;
font-size: 0;
}
/* FLECHA IZQUIERDA */
.flecha-anterior {
left: -66px;
background-image: url("https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhSGC9m2qw4IIBGcPclrcflgOn_iUnUWTHtoJL8RnX3wMTFJkMyj6PS7DZLLsQyuiUA_5G_pG5z9z-YQPB6cpLKLrJU21JH3_kM4EHr6jq8s0R34bBqG4zZCTlHlMZ8CRZ8-8d-Q002Olq3P5123O_GNRhRkYcuta4dIiQ0Y_eE7u-Nc4vHT3pWSYA12CA/s744/flecha%20editada%20izq.png");
}
/* FLECHA DERECHA */
.flecha-siguiente {
right: -66px;
background-image: url("https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgcVsazl3idsxnbOmE31fT68PfheJVC4efpnJG4zQV7v4-OA1MeccgpOTXPKt3phjOAhhxu-fattN8BeAbT4OnlM3ofXPCIPkAD9iEcKaI0Pi1cRtMPu-ujhkK4BEskL3PrfNzNGXyd7iM6OhmL8S5GVxgD4Fm8ztFFfuedg0Plb9M4EDkhghonTO_n4wA/s744/flecha%20editada%20der.png");
}
.foto-producto {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: contain;
display: block;
background: #fffdf8;
border: 1px solid #c3a45b;
box-shadow:
inset 0 0 0 1px #e0c77b,
0 7px 16px rgba(70, 70, 70, .60);
}
.foto-producto {
display: none;
opacity: 0;
}
.foto-producto.foto-activa {
display: block;
opacity: 1;
animation: fundido-foto .25s ease;
}
@keyframes fundido-foto {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.informacion {
background: #F3E8D4;
border: 1px solid #b9a27a;
padding: 30px;
position: sticky;
top: 20px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.42);
}
.informacion h1 {
margin: 0 0 18px;
font-size: 32px;
line-height: 1.2;
font-weight: normal;
}
.precio {
font-size: 30px;
font-weight: bold;
margin: 20px 0;
}
.categoria {
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
color: #806957;
margin-bottom: 20px;
}
.descripcion {
border-top: 1px solid #d8c9b2;
padding-top: 20px;
font-size: 17px;
line-height: 1.65;
}
.comprar {
display: block;
margin-top: 25px;
padding: 14px 20px;
background: #315442;
color: #f3ead7;
border: 1px solid #315442;
text-decoration: none;
font-size: 14px;
letter-spacing: .5px;
text-align: center;
transition: background .2s ease;
}
.comprar:hover {
background: #25D366;
}
@media (max-width: 800px) {
.producto-layout {
display: flex;
flex-direction: column;
align-items: center;
gap: 25px;
width: 100%;
}
.galeria {
width: 100%;
max-width: 100%;
margin: 0 auto;
}
.carrusel-fotos {
width: 100%;
}
.foto-producto {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: contain;
}
.flecha-carrusel {
width: 52px;
height: 52px;
top: 88%;
}
.flecha-anterior {
left: -5px;
}
.flecha-siguiente {
right: -5px;
}
.indicadores-carrusel {
margin-top: 10px;
}
.informacion {
position: static;
width: 100%;
box-sizing: border-box;
padding: 22px;
}
.informacion h1 {
font-size: 26px;
}
.precio {
font-size: 25px;
}
.ficha-marca {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
gap: 4px !important;
font-size: 30px !important;
}
.ficha-logo {
width: 48px !important;
height: 48px !important;
}
.ficha-subtitulo {
font-size: 14px !important;
}
}
.enlace-whatsapp {
color: #7a4b3f !important;
font-family: Georgia, "Times New Roman", serif;
font-size: 15px;
text-decoration: none;
}
.enlace-whatsapp:hover {
text-decoration: underline;
}
.ficha-enlace-inicio {
color: #7a4b3f !important;
font-family: Georgia, "Times New Roman", serif;
font-size: 15px;
text-decoration: none;
}
.ficha-enlace-inicio:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="zona-navegacion">
<button class="menu-boton" type="button" aria-label="Abrir menú">
<span></span>
<span></span>
</button>
<nav class="menu-desplegable">
<a href="/">INICIO</a>
<a href="/catalogo">CATÁLOGO</a>
<a href="/p/archivo-fotografico-de-1000-antiguedades.html">ARCHIVO FOTOGRÁFICO</a>
<a href="https://wa.me/573183853435" target="_blank">WHATSAPP</a>
</nav>
</div>
<header class="ficha-header">
<a
class="ficha-marca"
href="/"
aria-label="1000 Antigüedades — Inicio"
>
<img
class="ficha-logo"
src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjU6A7bAVT2SfsHJztJSDH6ZpGnW-JbcSlpF8Q02T5NCwf-wJaZ4qovnS4dTRQPwYz639E6jYq3m0TY3GQFVRfQwBDelnmsp2JuOqBx-2kODpLr2YQN9IdrtDV0BzoULUOl8S8SE6U-UHKHWSIypbtbo_eeYxU3HLn4dPTFnHeB5EL5n15RMkC-aic-kPg/s1254/2026-08%20LOGO%20REDONDO%20-%20sin%20fondo.png"
alt="1000 Antigüedades"
>
<span>
1000 ANTIGÜEDADES
</span>
</a>
<div class="ficha-subtitulo">
En cada pieza, una historia
</div>
</header>
<main
class="producto-contenedor"
>
<div
class="producto-layout"
>
<section class="galeria">
<button
class="flecha-carrusel flecha-anterior"
type="button"
aria-label="Foto anterior"
>
←
</button>
<div class="carrusel-fotos">
${galeria}
</div>
<div class="indicadores-carrusel"></div>
<button
class="flecha-carrusel flecha-siguiente"
type="button"
aria-label="Foto siguiente"
>
→
</button>
</section>
<aside
class="informacion"
>
<div
class="categoria"
>
${escapeHtml(
producto.categoria_tienda
|| "Antigüedades"
)}
</div>
<h1>
${escapeHtml(
producto.titulo
)}
</h1>
<div
class="precio"
>
$ ${precio}
${escapeHtml(
producto.moneda || "COP"
)}
</div>
<div
class="descripcion"
>
${descripcion}
</div>
<a
class="comprar"
href="https://wa.me/573183853435?text=${encodeURIComponent(
"Hola, estoy interesado en la pieza: " +
producto.titulo +
"\n" +
"https://www.1000antiguedades.com/producto/" +
producto.slug
)}"
target="_blank"
>
CONSULTAR POR WHATSAPP
</a>
</aside>
</div>
</main>
<script>
const fotosCarrusel =
document.querySelectorAll(".foto-producto");
const botonAnterior =
document.querySelector(".flecha-anterior");
const botonSiguiente =
document.querySelector(".flecha-siguiente");
const indicadores =
document.querySelector(".indicadores-carrusel");
let indiceActual = 0;
let inicioX = 0;
let inicioY = 0;
const zonaDeslizamiento =
document.querySelector(".carrusel-fotos");
zonaDeslizamiento.addEventListener(
"touchstart",
(evento) => {
inicioX = evento.touches[0].clientX;
inicioY = evento.touches[0].clientY;
},
{ passive: true }
);
zonaDeslizamiento.addEventListener(
"touchend",
(evento) => {
const finalX =
evento.changedTouches[0].clientX;
const finalY =
evento.changedTouches[0].clientY;
const diferenciaX =
finalX - inicioX;
const diferenciaY =
finalY - inicioY;
/* SOLO ACTUAR SI EL GESTO ES
PRINCIPALMENTE HORIZONTAL */
if (
Math.abs(diferenciaX) > 50 &&
Math.abs(diferenciaX) > Math.abs(diferenciaY)
) {
if (diferenciaX < 0) {
mostrarFoto(indiceActual + 1);
} else {
mostrarFoto(indiceActual - 1);
}
}
},
{ passive: true }
);
/* CREAR UN PUNTO POR CADA FOTO */
fotosCarrusel.forEach((foto, indice) => {
const punto =
document.createElement("button");
punto.type = "button";
punto.className =
"indicador-carrusel";
punto.setAttribute(
"aria-label",
"Ir a foto " + (indice + 1)
);
punto.addEventListener(
"click",
() => {
mostrarFoto(indice);
}
);
indicadores.appendChild(punto);
});
const puntos =
document.querySelectorAll(
".indicador-carrusel"
);
function mostrarFoto(indice) {
if (!fotosCarrusel.length) return;
fotosCarrusel[
indiceActual
].classList.remove("foto-activa");
indiceActual =
(indice + fotosCarrusel.length)
% fotosCarrusel.length;
fotosCarrusel[
indiceActual
].classList.add("foto-activa");
/* ACTUALIZAR PUNTO ACTIVO */
puntos.forEach((punto, i) => {
punto.classList.toggle(
"activo",
i === indiceActual
);
});
}
botonAnterior.addEventListener(
"click",
() => {
mostrarFoto(indiceActual - 1);
}
);
botonSiguiente.addEventListener(
"click",
() => {
mostrarFoto(indiceActual + 1);
}
);
/* MARCAR LA PRIMERA FOTO */
if (puntos.length) {
puntos[0].classList.add("activo");
}
const menuBoton = document.querySelector('.menu-boton');
const menuDesplegable = document.querySelector('.menu-desplegable');
menuBoton.addEventListener('click', function () {
menuBoton.classList.toggle('activo');
menuDesplegable.classList.toggle('abierto');
});
</script>
</body>
</html>`,
{
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
}
);
}
};