Add full project structure: backend API + frontend

- Move repo to project root to include both public/ and api/
- Add .gitignore excluding node_modules and .env
- Include API routes (erase, gmail_oauth), services (mailer), and config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hacklab 2026-04-07 12:09:54 +02:00
parent 36b918b95d
commit 614d5af397
20 changed files with 2419 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
.env
.claude/

16
api/.env.example Normal file
View file

@ -0,0 +1,16 @@
# ── Copia este fichero como .env y rellena los valores ──
# Sal aleatoria para hashes de referencia (pon cualquier string largo)
SALT=pon-aqui-una-cadena-aleatoria-larga
# Google OAuth2 (Gmail API)
# Obtén estas credenciales en: https://console.cloud.google.com/
# → APIs y servicios → Credenciales → Crear credenciales → ID de cliente OAuth2
# → Tipo: Aplicación web
# → URI de redirección autorizado: https://resetea.net/api/gmail/callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=https://resetea.net/api/gmail/callback
# Puerto del servidor (por defecto 8787)
PORT=8787

48
api/app.js Normal file
View file

@ -0,0 +1,48 @@
'use strict';
require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const eraseRoute = require('./routes/erase');
const gmailOAuth = require('./routes/gmail_oauth');
const app = express();
app.disable('x-powered-by');
app.use(helmet());
app.use(express.json({ limit: '10kb' }));
// Rate limiting general
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
}));
// Rate limiting específico para envío de emails (más estricto)
const emailLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hora
max: 10,
message: { error: 'Demasiadas solicitudes de envío. Espera antes de reintentar.' }
});
// ── Rutas ────────────────────────────────────────────────────────
app.post('/api/erase', emailLimit, eraseRoute);
app.get('/api/gmail/auth', emailLimit, gmailOAuth.authInit);
app.get('/api/gmail/callback', gmailOAuth.authCallback);
// Health check
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
// ── Arranque ─────────────────────────────────────────────────────
const PORT = process.env.PORT || 8787;
app.listen(PORT, '127.0.0.1', () => {
console.log(`RESETEA backend corriendo en 127.0.0.1:${PORT}`);
if (!process.env.GOOGLE_CLIENT_ID) {
console.warn('⚠ GOOGLE_CLIENT_ID no configurado — OAuth Gmail deshabilitado');
}
});

1820
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

19
api/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "resetea-api",
"version": "1.0.0",
"description": "RESETEA.NET — API backend para solicitudes GDPR",
"main": "app.js",
"scripts": {
"start": "node app.js",
"dev": "node --watch app.js"
},
"engines": { "node": ">=18" },
"dependencies": {
"express": "^4.19.2",
"helmet": "^7.1.0",
"express-rate-limit": "^7.3.1",
"nodemailer": "^6.9.13",
"googleapis": "^144.0.0",
"dotenv": "^16.4.5"
}
}

55
api/routes/erase.js Normal file
View file

@ -0,0 +1,55 @@
'use strict';
const crypto = require('crypto');
const { sendErasureMail, PROVIDER_DATA } = require('../services/mailer');
const ALLOWED_PROVIDERS = new Set(Object.keys(PROVIDER_DATA));
module.exports = async (req, res) => {
try {
const { provider, email, nickname, phone, address, extra } = req.body;
// Validación mínima
if (!provider || !email) {
return res.status(400).json({ error: 'provider y email son obligatorios' });
}
// Validar proveedor conocido (previene abusos de relay)
if (!ALLOWED_PROVIDERS.has(provider)) {
return res.status(400).json({ error: 'Proveedor no soportado. Usa el formulario oficial.' });
}
// Validación básica de email
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({ error: 'Email inválido' });
}
// Hash irreversible para referencia (auditoría sin almacenar PII)
const hash = crypto
.createHash('sha256')
.update(email + (process.env.SALT || 'resetea-default-salt'))
.digest('hex');
const result = await sendErasureMail({ provider, email, nickname, phone, address, extra });
if (result.skipped) {
return res.json({
status: 'use_form',
message: 'Este proveedor no acepta solicitudes por email. Usa su formulario oficial.',
formUrl: result.formUrl,
reference: hash.substring(0, 12),
});
}
// PII fuera de scope aquí — solo el hash queda
res.json({
status: 'ok',
message: 'Solicitud enviada. Guarda el código de referencia.',
reference: hash.substring(0, 12),
});
} catch (e) {
console.error('erase route error:', e.message);
res.status(500).json({ error: 'Error interno. Inténtalo de nuevo o usa el formulario oficial del proveedor.' });
}
};

131
api/routes/gmail_oauth.js Normal file
View file

@ -0,0 +1,131 @@
'use strict';
/**
* RESETEA.NET OAuth Gmail
*
* Flujo:
* GET /api/gmail/auth redirige a Google para autorización
* GET /api/gmail/callback intercambia code por token de un solo uso
* POST /api/gmail/send envía la carta GDPR desde el Gmail del usuario
* (el token se usa y se descarta nunca se persiste)
*
* PREREQUISITO:
* Crea un proyecto en Google Cloud Console:
* https://console.cloud.google.com/
* Habilita "Gmail API"
* Crea credenciales OAuth2 (tipo "Aplicación web")
* URI de redirección: https://resetea.net/api/gmail/callback
* Copia GOOGLE_CLIENT_ID y GOOGLE_CLIENT_SECRET en .env
*/
const { google } = require('googleapis');
const { buildLetterText, PROVIDER_DATA } = require('../services/mailer');
// ── Estado temporal en memoria (un objeto por token de sesión) ──
// NO se persiste en disco. Si el servidor reinicia, se pierden los
// tokens pendientes (el usuario debe repetir el flujo OAuth).
const pendingSends = new Map(); // sessionId → { token, letterParams }
function getOAuth2Client() {
return new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI || 'https://resetea.net/api/gmail/callback'
);
}
// ── GET /api/gmail/auth ──────────────────────────────────────────
// El frontend envía los parámetros de la carta como query params
// para que los podamos recuperar en el callback.
exports.authInit = (req, res) => {
const { provider, name, email, nickname, phone, address, extra, requestType } = req.query;
if (!provider || !email || !name) {
return res.status(400).json({ error: 'Faltan parámetros obligatorios (provider, name, email).' });
}
if (!PROVIDER_DATA[provider]) {
return res.status(400).json({ error: 'Proveedor no soportado.' });
}
// Guardamos el state en base64 (no sensible: no contiene credenciales)
const state = Buffer.from(JSON.stringify({ provider, name, email, nickname, phone, address, extra, requestType })).toString('base64url');
const oauth2Client = getOAuth2Client();
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'online', // no refresh token — uso puntual
scope: ['https://www.googleapis.com/auth/gmail.send'],
prompt: 'consent',
state,
});
res.redirect(authUrl);
};
// ── GET /api/gmail/callback ──────────────────────────────────────
exports.authCallback = async (req, res) => {
const { code, state, error } = req.query;
if (error) {
return res.redirect('/plantillas.html?oauth=cancelled');
}
if (!code || !state) {
return res.status(400).send('Parámetros OAuth inválidos.');
}
let params;
try {
params = JSON.parse(Buffer.from(state, 'base64url').toString());
} catch {
return res.status(400).send('State OAuth inválido.');
}
try {
const oauth2Client = getOAuth2Client();
const { tokens } = await oauth2Client.getToken(code);
// Enviamos el email inmediatamente (no almacenamos el token)
oauth2Client.setCredentials(tokens);
const gmail = google.gmail({ version: 'v1', auth: oauth2Client });
const providerInfo = PROVIDER_DATA[params.provider];
if (!providerInfo.email) {
return res.redirect(`/plantillas.html?oauth=no_email&provider=${params.provider}&formUrl=${encodeURIComponent(providerInfo.formUrl || '')}`);
}
const letterText = buildLetterText({
providerInfo,
senderName: params.name,
senderEmail: params.email,
senderNick: params.nickname || '',
senderPhone: params.phone || '',
senderAddress: params.address || '',
extra: params.extra || '',
});
// Construir email RFC 2822 en base64url
const subject = `Ejercicio derecho de supresión (RGPD Art. 17) — ${providerInfo.name}`;
const rawEmail = [
`From: ${params.name} <${params.email}>`,
`To: ${providerInfo.email}`,
`Subject: ${subject}`,
`MIME-Version: 1.0`,
`Content-Type: text/plain; charset=UTF-8`,
'',
letterText,
].join('\r\n');
const encoded = Buffer.from(rawEmail).toString('base64url');
await gmail.users.messages.send({
userId: 'me',
requestBody: { raw: encoded },
});
// Token descartado aquí (fuera de scope, GC lo recogerá)
res.redirect(`/plantillas.html?oauth=ok&provider=${encodeURIComponent(providerInfo.name)}`);
} catch (err) {
console.error('Gmail OAuth error:', err.message);
res.redirect('/plantillas.html?oauth=error');
}
};

117
api/services/mailer.js Normal file
View file

@ -0,0 +1,117 @@
'use strict';
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
sendmail: true,
newline: 'unix',
path: '/usr/sbin/sendmail'
});
/* ============================================================
DATOS DE PROVEEDORES
Actualizar si cambian los contactos de privacidad.
============================================================ */
const PROVIDER_DATA = {
instagram: { name: 'Instagram (Meta)', email: 'privacidad-dpo@fb.com', company: 'Meta Platforms Ireland Limited', address: '4 Grand Canal Square, Grand Canal Harbour, Dublín 2, Irlanda' },
facebook: { name: 'Facebook (Meta)', email: 'privacidad-dpo@fb.com', company: 'Meta Platforms Ireland Limited', address: '4 Grand Canal Square, Grand Canal Harbour, Dublín 2, Irlanda' },
twitter_x: { name: 'X (Twitter)', email: 'gdpr@twitter.com', company: 'Twitter International Unlimited Company', address: 'One Cumberland Place, Fenian Street, Dublín 2, D02 AX07, Irlanda' },
google: { name: 'Google', email: null, formUrl: 'https://support.google.com/policies/contact/sar_data_protection', company: 'Google Ireland Limited', address: 'Gordon House, Barrow Street, Dublín 4, Irlanda' },
linkedin: { name: 'LinkedIn', email: 'privacy@linkedin.com', company: 'LinkedIn Ireland Unlimited Company', address: 'Wilton Place, Dublín 2, Irlanda' },
tiktok: { name: 'TikTok', email: 'privacy@tiktok.com', company: 'TikTok Technology Limited', address: '10 Earlsfort Terrace, Dublín, D02 T380, Irlanda' },
snapchat: { name: 'Snapchat', email: 'privacy@snap.com', company: 'Snap Group Limited', address: '77 Shaftesbury Avenue, Londres W1D 5DU, Reino Unido' },
microsoft: { name: 'Microsoft', email: 'msprivacy@microsoft.com', company: 'Microsoft Ireland Operations Limited', address: 'One Microsoft Place, South County Business Park, Leopardstown, Dublín 18, Irlanda' },
apple: { name: 'Apple', email: 'privacy@apple.com', company: 'Apple Distribution International Ltd.', address: 'Hollyhill Industrial Estate, Hollyhill, Cork, Irlanda' },
amazon: { name: 'Amazon', email: null, formUrl: 'https://www.amazon.es/hz/contact-us/privacy-disclosure/', company: 'Amazon Europe Core S.à r.l.', address: '38 avenue John F. Kennedy, L-1855 Luxemburgo' },
reddit: { name: 'Reddit', email: 'gdpr@reddit.com', company: 'Reddit Inc.', address: '548 Market St. #16093, San Francisco, CA 94104, EE.UU.' },
discord: { name: 'Discord', email: 'privacy@discord.com', company: 'Discord Inc.', address: '444 De Haro Street, Suite 200, San Francisco, CA 94107, EE.UU.' },
};
/* ============================================================
PLANTILLA DE CARTA supresión Art. 17 RGPD
============================================================ */
function buildLetterText({ providerInfo, senderName, senderEmail, senderNick, senderPhone, senderAddress, extra }) {
const today = new Date().toLocaleDateString('es-ES', { day: 'numeric', month: 'long', year: 'numeric' });
const deadline = new Date(Date.now() + 30 * 86400000).toLocaleDateString('es-ES', { day: 'numeric', month: 'long', year: 'numeric' });
const identLines = [
`Nombre: ${senderName}`,
`Email: ${senderEmail}`,
senderNick ? `Usuario / alias: ${senderNick}` : null,
senderPhone ? `Teléfono: ${senderPhone}` : null,
senderAddress ? `Dirección: ${senderAddress}` : null,
extra ? `Datos adicionales: ${extra}` : null,
].filter(Boolean).join('\n');
return `${providerInfo.company}
${providerInfo.address}
${providerInfo.email ? 'DPO: ' + providerInfo.email : ''}
${today}
Asunto: Ejercicio del derecho de supresión artículo 17 RGPD (UE 2016/679)
A quien corresponda:
Yo, ${senderName}, con los siguientes datos de contacto:
${identLines}
En ejercicio del derecho de supresión ("derecho al olvido") reconocido en el artículo 17 del Reglamento (UE) 2016/679 (RGPD) y en la Ley Orgánica 3/2018, de Protección de Datos Personales y garantía de los derechos digitales (LOPDGDD), solicito a ${providerInfo.company} la supresión completa de todos mis datos personales, incluyendo sin limitación:
· Datos de cuenta e identificadores (nombre, email, teléfono, dirección IP, identificadores de dispositivo).
· Contenidos publicados, comentarios, mensajes, imágenes y archivos.
· Datos de comportamiento, historial de actividad y preferencias inferidas.
· Datos en sistemas de backup y réplicas, en el plazo razonable comprometido.
· Datos cedidos o compartidos con terceros, con la notificación correspondiente para que también procedan a su supresión.
Fundamento mi solicitud en la retirada del consentimiento que en su momento presté y en que el tratamiento de mis datos ya no es necesario para los fines para los que fueron recabados.
Solicito confirmación escrita de la supresión una vez completada, con indicación del plazo para la eliminación de backups.
De conformidad con el artículo 12.3 RGPD, disponen de un mes desde la recepción para responder (antes del ${deadline}). En caso de denegación o silencio, me reservo el derecho a presentar reclamación ante la Agencia Española de Protección de Datos (sedeagpd.gob.es).
Atentamente,
${senderName}
${senderEmail}
${today}`;
}
/* ============================================================
EXPORTACIONES
============================================================ */
exports.PROVIDER_DATA = PROVIDER_DATA;
exports.sendErasureMail = async ({ provider, email, nickname, phone, address, extra }) => {
const providerInfo = PROVIDER_DATA[provider] || {
name: provider,
email: `privacy@${provider}.com`,
company: provider,
address: '',
};
// Si el proveedor no tiene email directo, no enviamos (devolvemos aviso)
if (!providerInfo.email) {
return { skipped: true, reason: 'use_form', formUrl: providerInfo.formUrl };
}
const letterText = buildLetterText({
providerInfo,
senderName: email, // El nombre real viene del campo email por compatibilidad heredada
senderEmail: email,
senderNick: nickname || '',
senderPhone: phone || '',
senderAddress: address || '',
extra: extra || '',
});
await transporter.sendMail({
from: 'privacy@resetea.net',
to: providerInfo.email,
subject: `Ejercicio derecho de supresión (RGPD Art. 17) — ${providerInfo.name}`,
text: letterText,
});
return { skipped: false };
};

210
context_puppeteer.txt Normal file
View file

@ -0,0 +1,210 @@
═══════════════════════════════════════════════════════════════════
RESETEA.NET — CONTEXTO PARA AUTOMATIZACIÓN CON PUPPETEER (LOCAL)
Creado: 2026-04 | Estado: pendiente de implementar
═══════════════════════════════════════════════════════════════════
OBJETIVO
────────
Permitir que el propio usuario ejecute una automatización local
(Puppeteer corriendo en su máquina) para completar flujos de
eliminación de cuentas y envío de solicitudes GDPR sin ceder
credenciales a ningún servidor externo.
El servidor de resetea.net solo entrega el script. La ejecución
ocurre 100 % en el dispositivo del usuario.
─────────────────────────────────────────────────────────────────
ARQUITECTURA PROPUESTA
─────────────────────────────────────────────────────────────────
resetea.net (servidor)
└── Entrega scripts Puppeteer por plataforma vía descarga directa
scripts/puppeteer/instagram_delete.js
scripts/puppeteer/facebook_delete.js
scripts/puppeteer/twitter_deactivate.js
scripts/puppeteer/google_delete.js
scripts/puppeteer/linkedin_close.js
scripts/puppeteer/gdpr_email_sender.js ← usa Gmail local
Usuario (local)
├── Instala Node.js + Puppeteer (npx puppeteer)
├── Descarga el script de resetea.net
├── Ejecuta: node instagram_delete.js
└── El script abre Chromium, el usuario introduce credenciales
manualmente en la ventana del navegador (headful, no headless)
y el script guía los clics del flujo de eliminación.
─────────────────────────────────────────────────────────────────
DECISIONES DE DISEÑO IMPORTANTES
─────────────────────────────────────────────────────────────────
1. SIEMPRE headful (con ventana visible)
- El usuario ve exactamente qué está pasando.
- Las credenciales las teclea el usuario, no el script.
- Reduce enormemente el riesgo de bloqueo por parte de las
plataformas (2FA, detección de bots, etc.).
2. NUNCA enviar credenciales al servidor
- El script no hace fetch() con passwords.
- No hay logging de sesiones en el servidor.
3. Consentimiento explícito
- El script muestra en terminal qué va a hacer antes de cada acción.
- Requiere confirmación (Y/n) para pasos destructivos.
4. Pausa antes de acciones irreversibles
- Antes de confirmar la eliminación: pausa de 10 segundos
con mensaje claro en terminal.
5. Modo "dry-run"
- node instagram_delete.js --dry-run
- Navega y muestra qué haría pero no hace clic en "Confirmar".
─────────────────────────────────────────────────────────────────
LEGALIDAD Y TÉRMINOS DE SERVICIO
─────────────────────────────────────────────────────────────────
ZONA GRIS LEGAL:
- La mayoría de ToS prohíben acceso automatizado. Sin embargo,
cuando el objetivo es ejercer un derecho legal (RGPD Art. 17)
y el usuario opera desde su propio dispositivo con sus propias
credenciales, la práctica es equiparable a usar un gestor de
contraseñas o un asistente de accesibilidad.
- El Tribunal de Justicia de la UE (sentencia hiQ vs LinkedIn, 2022)
ha matizado que bloquear el ejercicio de derechos mediante
medidas técnicas puede ser contrario al RGPD.
- Riesgo real: la plataforma puede detectar el bot y bloquear la
cuenta antes de completar la eliminación. Mitigación: modo headful
+ pasos con delays humanos (randomDelay helper).
- Recomendación: incluir en cada script un aviso legal y que el
usuario acepte explícitamente antes de ejecutar.
ACCIONES SEGURAS (bajo riesgo de detección):
✓ Navegar a la URL de eliminación y hacer click en botones
del flujo oficial.
✓ Rellenar el formulario de supresión GDPR que la plataforma
ya ofrece públicamente.
✗ Scraping masivo de datos de la plataforma.
✗ Publicar, enviar mensajes o modificar contenido.
─────────────────────────────────────────────────────────────────
STACK TÉCNICO
─────────────────────────────────────────────────────────────────
Dependencias del script de usuario:
node >= 18
puppeteer >= 22 (incluye Chromium)
inquirer >= 9 (prompts en terminal)
chalk >= 5 (colores en terminal)
Instalación rápida:
npm init -y && npm install puppeteer inquirer chalk
─────────────────────────────────────────────────────────────────
ESTRUCTURA DE SCRIPT TIPO (Instagram)
─────────────────────────────────────────────────────────────────
// instagram_delete.js
import puppeteer from 'puppeteer';
import inquirer from 'inquirer';
import chalk from 'chalk';
const STEPS = [
{ url: 'https://www.instagram.com/accounts/login/', action: 'LOGIN' },
{ url: 'https://www.instagram.com/accounts/remove/request/permanent/', action: 'DELETE' },
];
async function run() {
console.log(chalk.yellow('⚠ RESETEA.NET — Eliminación de cuenta Instagram'));
console.log(chalk.gray('Este script navega por los pasos oficiales de Instagram.'));
console.log(chalk.gray('Tus credenciales NUNCA salen de tu dispositivo.\n'));
const { confirmed } = await inquirer.prompt([{
type: 'confirm', name: 'confirmed',
message: '¿Confirmas que quieres iniciar el proceso de eliminación de tu cuenta Instagram?',
default: false,
}]);
if (!confirmed) process.exit(0);
const browser = await puppeteer.launch({ headless: false, defaultViewport: null });
const page = await browser.newPage();
// Paso 1: Login manual
console.log(chalk.cyan('\n→ Abriendo Instagram. Introduce tus credenciales en la ventana del navegador.'));
await page.goto(STEPS[0].url, { waitUntil: 'networkidle2' });
// Espera a que el usuario haga login manualmente
await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 120000 });
// Paso 2: Navegar a eliminación
console.log(chalk.cyan('\n→ Navegando a la página de eliminación de cuenta...'));
await page.goto(STEPS[1].url, { waitUntil: 'networkidle2' });
console.log(chalk.red('\n⚠ ATENCIÓN: Tienes 10 segundos para cancelar (Ctrl+C)'));
await new Promise(r => setTimeout(r, 10000));
// TODO: Localizar y hacer clic en el botón de confirmación
// (los selectores cambian con las actualizaciones de Instagram)
// await page.click('button[data-testid="delete-account-confirm"]');
console.log(chalk.green('\n✓ Proceso completado. Cierra el navegador cuando hayas terminado.'));
}
run().catch(console.error);
─────────────────────────────────────────────────────────────────
HELPER: DELAYS HUMANOS (anti-detección)
─────────────────────────────────────────────────────────────────
function randomDelay(min = 800, max = 2400) {
return new Promise(r => setTimeout(r, min + Math.random() * (max - min)));
}
// Uso: await randomDelay(); entre cada acción
─────────────────────────────────────────────────────────────────
ROADMAP DE IMPLEMENTACIÓN
─────────────────────────────────────────────────────────────────
Fase 1 (MVP):
[ ] Script Instagram (ruta más común)
[ ] Script Facebook
[ ] Script X/Twitter
[ ] Página de descarga en resetea.net con instrucciones claras
[ ] Aviso legal que el usuario acepta antes de descargar
Fase 2:
[ ] Script LinkedIn
[ ] Script TikTok (solo app móvil — requiere ADB o alternativa)
[ ] Script Google (flujo myaccount)
[ ] Auto-update de selectores (GitHub Actions que verifica mensualmente)
Fase 3:
[ ] Modo monitor: re-ejecuta opt-outs de data brokers cada 90 días
[ ] Script para AEPD (rellenar formulario de reclamación si no responden)
─────────────────────────────────────────────────────────────────
NOTAS OPERATIVAS
─────────────────────────────────────────────────────────────────
- Los selectores CSS de las plataformas cambian con frecuencia.
Hay que mantener los scripts actualizados (punto de fricción principal).
- Instagram y Facebook detectan Puppeteer por el user-agent y
ciertas propiedades de navigator. Usar puppeteer-extra +
stealth plugin para mitigarlo:
npm install puppeteer-extra puppeteer-extra-plugin-stealth
- TikTok solo permite eliminar cuenta desde la app móvil.
Alternativa: ADB (Android Debug Bridge) + UI Automator, o
instrucciones manuales muy detalladas con screenshots.
- Guardar los selectores en un fichero JSON separado (selectors.json)
para que sea fácil actualizarlos sin tocar la lógica del script.
═══════════════════════════════════════════════════════════════════
FIN DEL CONTEXTO — resetea.net/context_puppeteer.txt
═══════════════════════════════════════════════════════════════════