#!/usr/bin/env node /** * Script para descargar el contenido del curso de Voley desde * Online Education Center usando Playwright. * * Versión mejorada: navega directamente a cada sección del curso, * extrae contenido textual, PDFs, y referencias a videos. * * Modo de uso: * node download_course.mjs * * Requisitos: * - npm install playwright * - npx playwright install chromium * * Para reanudar una descarga interrumpida: * Simplemente se vuelve a ejecutar, saltea archivos ya existentes. */ import { chromium } from "playwright"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DOWNLOADS_DIR = path.join(__dirname, "descargas"); const CREDENTIALS = { username: "saldis_karina@hotmail.com", password: "Martes13#", }; // Lista de todas las secciones del curso (Módulo 1 y Módulo 2) const SECCIONES = [ // Módulo 1 (m/19096) { modulo: "Modulo_1_Planificacion", nombre: "Planificacion", id: "s/34868", seccion: 34868, }, { modulo: "Modulo_1_Planificacion", nombre: "Preparacion_Atletica", id: "s/34869", seccion: 34869, }, { modulo: "Modulo_1_Planificacion", nombre: "Explicacion_TP", id: "s/34870", seccion: 34870, }, { modulo: "Modulo_1_Planificacion", nombre: "Seguimientos_Equipos", id: "s/35523", seccion: 35523, }, { modulo: "Modulo_1_Planificacion", nombre: "Analisis_Equipos_1", id: "s/35524", seccion: 35524, }, { modulo: "Modulo_1_Planificacion", nombre: "Analisis_Equipos_2", id: "s/35525", seccion: 35525, }, { modulo: "Modulo_1_Planificacion", nombre: "TP_Final_Unidad", id: "s/35526", seccion: 35526, }, // Módulo 2 (m/19097) { modulo: "Modulo_2_Entrenamiento", nombre: "Centrales_y_Oponentes", id: "s/35519", seccion: 35519, }, { modulo: "Modulo_2_Entrenamiento", nombre: "Puntas_y_Liberos", id: "s/35520", seccion: 35520, }, { modulo: "Modulo_2_Entrenamiento", nombre: "Entrenamiento_Armadoras", id: "s/35521", seccion: 35521, }, { modulo: "Modulo_2_Entrenamiento", nombre: "Explicacion_TP", id: "s/35522", seccion: 35522, }, ]; // IDs de los módulos (necesarios para construir las URLs) const MODULO_1 = "m/19096"; const MODULO_2 = "m/19097"; const CAMPUS_BASE = "https://voley.onlineeducation.center/es/campus/student/training/e/14197"; // ============================================================ // UTILIDADES // ============================================================ function ensureDir(dir) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } function log(msg) { const ts = new Date().toISOString().replace("T", " ").substring(0, 19); console.log(`[${ts}] ${msg}`); } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } // ============================================================ // DESCARGA DE ARCHIVOS // ============================================================ /** * Descarga un archivo desde una URL usando el evento download de Playwright. * La URL corresponde a un enlace Abrir Archivo * que inicia una descarga nativa del navegador. * * En lugar de navegar con page.goto() (que carga la respuesta como página), * buscamos el enlace por su href y hacemos click para disparar el evento download. */ async function downloadFile(page, url, filepath) { if (fs.existsSync(filepath)) { log(` ↺ Ya existe: ${path.basename(filepath)}`); return filepath; } try { // Buscar el enlace en la página por su URL exacta const link = page.locator(`a[href="${url}"]`); const linkCount = await link.count(); if (linkCount === 0) { throw new Error(`No se encontró el enlace: ${url}`); } // Configurar la espera del evento download antes de hacer click const [download] = await Promise.all([ page.waitForEvent("download", { timeout: 30000 }), link.first().click(), ]); // Guardar el archivo descargado await download.saveAs(filepath); const suggestedName = download.suggestedFilename(); const sizeKB = (fs.statSync(filepath).size / 1024).toFixed(1); log( ` ✓ Descargado: ${path.basename(filepath)} (${sizeKB} KB) — original: ${suggestedName || "desconocido"}`, ); return filepath; } catch (err) { log(` ✗ Error descarga: ${err.message}`); return null; } } /** * Toma screenshot de la página actual. */ async function screenshot(page, name) { const dir = path.join(DOWNLOADS_DIR, "screenshots"); ensureDir(dir); const filepath = path.join(dir, name); await page.screenshot({ path: filepath, fullPage: true }); return filepath; } /** * Extrae el contenido textual de la página como Markdown simple. */ async function extractTextAsMarkdown(page, titulo) { const text = await page.evaluate(() => { // Intentar extraer el contenido principal const main = document.querySelector( "main, .main-content, .content, #content, .panel-body, .tab-content", ); if (!main) return ""; // Clonar para no modificar el DOM real const clone = main.cloneNode(true); // Remover elementos no deseados clone .querySelectorAll( "script, style, nav, footer, header, .btn, .dropdown-menu", ) .forEach((el) => el.remove()); // Obtener el texto return clone.innerText.trim(); }); if (!text) return ""; const lines = text.split("\n").filter((l) => l.trim()); const md = [ `# ${titulo}`, "", `> Extraído el ${new Date().toISOString().split("T")[0]}`, "", ...lines.map((l) => l.trim()), "", ].join("\n"); return md; } // ============================================================ // PROCESAR UNA SECCIÓN DEL CURSO // ============================================================ async function procesarSeccion(page, seccion) { // Determinar qué módulo le corresponde const moduloPath = seccion.seccion >= 35519 ? MODULO_2 : MODULO_1; // URL de la página de recursos de esta sección const seccionUrl = `${CAMPUS_BASE}/${moduloPath}/${seccion.id}/study-resources/list`; const subdir = path.join("secciones", seccion.modulo, seccion.nombre); const dirLocal = path.join(DOWNLOADS_DIR, subdir); ensureDir(dirLocal); log(` ── ${seccion.modulo}/${seccion.nombre}`); try { await page.goto(seccionUrl, { waitUntil: "networkidle", timeout: 30000 }); await sleep(2000); // ---- 1. Guardar HTML completo ---- const htmlContent = await page.content(); fs.writeFileSync(path.join(dirLocal, "pagina.html"), htmlContent); // ---- 2. Extraer texto como Markdown ---- const md = await extractTextAsMarkdown( page, seccion.nombre.replace(/_/g, " "), ); if (md) { fs.writeFileSync(path.join(dirLocal, "contenido.md"), md); } else { // Si no se encontró contenido principal, guardar todo el texto visible const fullText = await page.evaluate(() => document.body.innerText.trim(), ); if (fullText) { fs.writeFileSync( path.join(dirLocal, "contenido.md"), `# ${seccion.nombre.replace(/_/g, " ")}\n\n${fullText}\n`, ); } } // ---- 3. Buscar recursos en la página ---- const recursos = await page.evaluate((seccionUrlLocal) => { const items = []; // Buscar enlaces a recursos/archivos del curso document.querySelectorAll("a[href]").forEach((el) => { const href = el.href; // Enlaces a archivos del curso (study-resources/{id}/show) if (href.includes("/study-resources/") && href.includes("/show")) { items.push({ tipo: "archivo-curso", url: href, texto: el.textContent.trim(), id: href.match(/study-resources\/(\d+)\/show/)?.[1] || "", }); } // PDFs directos else if ( href.match(/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|zip|rar)(\?|$)/i) ) { items.push({ tipo: "documento", url: href, texto: el.textContent.trim(), }); } // Enlaces de descarga else if ( href.includes("/download/") || href.includes("download=") || el.hasAttribute("download") ) { items.push({ tipo: "descarga", url: href, texto: el.textContent.trim(), }); } }); // Buscar iframes (videos Vimeo/YouTube embebidos) document.querySelectorAll("iframe[src]").forEach((el) => { const src = el.src; if ( src.includes("vimeo.com") || src.includes("youtube.com") || src.includes("youtu.be") ) { const vimeoMatch = src.match(/vimeo\.com\/video\/(\d+)/); const youtubeMatch = src.match( /(?:youtube\.com\/embed\/|youtu\.be\/)([a-zA-Z0-9_-]+)/, ); items.push({ tipo: "video-embed", plataforma: src.includes("vimeo") ? "Vimeo" : "YouTube", url: src, id: vimeoMatch?.[1] || youtubeMatch?.[1] || "", titulo: el.title || "", }); } // Otros iframes (posiblemente contenido útil) else { items.push({ tipo: "iframe", url: src, titulo: el.title || "", }); } }); // Buscar videos directos document .querySelectorAll("video[src], video source[src]") .forEach((el) => { items.push({ tipo: "video-directo", url: el.src || el.getAttribute("src") || "", }); }); // Buscar imágenes relevantes (saltar iconos pequeños) document.querySelectorAll("img[src]").forEach((el) => { const src = el.src; if ( src && !src.includes("data:") && !src.includes("user-default") && !src.includes("logo") ) { items.push({ tipo: "imagen", url: src, alt: el.alt || "", }); } }); // Buscar contenido en etiquetas o document.querySelectorAll("embed[src], object[data]").forEach((el) => { const src = el.src || el.getAttribute("data") || ""; if (src) { items.push({ tipo: "embed", url: src, }); } }); return items; }); // ---- 4. Guardar JSON de recursos encontrados ---- if (recursos.length > 0) { fs.writeFileSync( path.join(dirLocal, "recursos.json"), JSON.stringify(recursos, null, 2), ); } log( ` Recursos: ${recursos.length} (${recursos.filter((r) => r.tipo === "archivo-curso").length} archivos, ${recursos.filter((r) => r.tipo === "video-embed").length} videos)`, ); // ---- 5. Descargar archivos del curso (PDFs, docs, etc.) ---- // Distinguir entre "Abrir Archivo" (descarga real de archivo) y // "Abrir Enlace" (página HTML que ya capturamos como pagina.html). const archivosCurso = recursos.filter((r) => r.tipo === "archivo-curso"); for (let i = 0; i < archivosCurso.length; i++) { const archivo = archivosCurso[i]; const texto = archivo.texto.toLowerCase(); // Saltar "Abrir Enlace": ya se capturó como HTML en pagina.html if (texto.includes("abrir enlace")) { log( ` ↺ Saltando "${archivo.texto}" (${archivo.id}): ya capturado como HTML`, ); continue; } // Solo procesar "Abrir Archivo" (descarga real) if (!texto.includes("abrir archivo")) { log( ` ↺ Saltando "${archivo.texto}" (${archivo.id}): tipo de recurso no manejado`, ); continue; } const ext = ".bin"; // placeholder, lo detectaremos del contenido const filename = `archivo_${archivo.id}_${archivo.texto.replace(/[^a-zA-Z0-9_-]/g, "_").substring(0, 30) || "documento"}${ext}`; const filepath = path.join(dirLocal, filename); log( ` Descargando archivo ${i + 1}/${archivosCurso.length}: "${archivo.texto}" (${archivo.id})...`, ); const result = await downloadFile(page, archivo.url, filepath); // Clasificar el archivo por su contenido (magic bytes) y renombrar if (result) { const stat = fs.statSync(result); if (stat.size > 0) { const buffer = fs.readFileSync(result); let newPath = null; if ( buffer[0] === 0x25 && buffer[1] === 0x50 && buffer[2] === 0x44 && buffer[3] === 0x46 ) { // Es PDF newPath = result.replace(/\.bin$/, ".pdf"); fs.renameSync(result, newPath); log(` ✓ Identificado como PDF: ${path.basename(newPath)}`); } else if (buffer[0] === 0x50 && buffer[1] === 0x4b) { // Es ZIP (posiblemente .docx, .xlsx, .pptx) newPath = result.replace(/\.bin$/, ".zip"); fs.renameSync(result, newPath); log( ` ⚠ Identificado como ZIP (Office doc?): ${path.basename(newPath)}`, ); } else if ( buffer[0] === 0x3c || // '<' posible HTML (buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) || // BOM UTF-8 buffer.toString("utf8", 0, 100).includes(" r.tipo === "video-embed"); if (videos.length > 0) { let videoMd = `# Videos - ${seccion.nombre.replace(/_/g, " ")}\n\n`; videoMd += `> Sección: ${seccionUrl}\n\n`; for (const v of videos) { videoMd += `## ${v.titulo || `Video ${v.plataforma} ID: ${v.id}`}\n\n`; videoMd += `- **Plataforma**: ${v.plataforma}\n`; videoMd += `- **URL**: ${v.url}\n`; videoMd += `- **ID**: ${v.id}\n\n`; if (v.plataforma === "Vimeo") { videoMd += `Para descargar: yt-dlp "https://player.vimeo.com/video/${v.id}"\n\n`; } } fs.writeFileSync(path.join(dirLocal, "videos.md"), videoMd); } // ---- 7. Tomar screenshot de referencia ---- await screenshot(page, `${seccion.modulo}_${seccion.nombre}.png`); log(` ✓ Completado: ${seccion.nombre}`); return { ok: true, recursos: recursos.length }; } catch (err) { log(` ✗ Error en sección ${seccion.nombre}: ${err.message}`); try { await screenshot(page, `ERROR_${seccion.modulo}_${seccion.nombre}.png`); } catch (_) {} return { ok: false, error: err.message }; } } // ============================================================ // FUNCIÓN PRINCIPAL // ============================================================ async function main() { log("═══════════════════════════════════════════════════════"); log(" DESCARGA DEL CURSO DE VOLEY - Online Education Center"); log("═══════════════════════════════════════════════════════"); log(""); // 1. Login log("Paso 1: Login en la plataforma..."); const browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"], }); const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, locale: "es-AR", }); const page = await context.newPage(); const sessionFile = path.join(__dirname, "session.json"); let loggedIn = false; let activeContext = context; let activePage = page; try { if (fs.existsSync(sessionFile)) { log(" Cargando sesión guardada..."); try { const sessionContext = await browser.newContext({ storageState: sessionFile, }); const sessionPage = await sessionContext.newPage(); await sessionPage.goto(CAMPUS_BASE, { waitUntil: "networkidle", timeout: 20000, }); const url = sessionPage.url(); if (url.includes("/campus/")) { loggedIn = true; log(" ✓ Sesión recuperada exitosamente"); // Cerrar page/context original, usar los nuevos await activePage.close(); await activeContext.close(); activePage = sessionPage; activeContext = sessionContext; } else { await sessionPage.close(); await sessionContext.close(); } } catch (err) { log(` ⚠ No se pudo reusar sesión: ${err.message}`); } } if (!loggedIn) { log(" Abriendo página de login..."); await page.goto( "https://voley.onlineeducation.center/es/campus/student/training/e/14197/m/19097/s/35519/study-resources/list", { waitUntil: "networkidle", timeout: 30000 }, ); await page.waitForSelector("#login-email", { timeout: 15000 }); await page.fill("#login-email", CREDENTIALS.username); await page.fill("#login-password", CREDENTIALS.password); await page.click("#btn-login"); await page.waitForURL("**/campus/**", { timeout: 30000 }); log(" ✓ Login exitoso"); // Guardar sesión para futuras ejecuciones await activeContext.storageState({ path: sessionFile }); log(" ✓ Sesión guardada en session.json"); } // 2. Navegar cada sección del curso log(""); log("Paso 2: Descargando contenido de cada sección..."); log(`Total de secciones: ${SECCIONES.length}`); log(""); const resultados = []; for (let i = 0; i < SECCIONES.length; i++) { log(`[${i + 1}/${SECCIONES.length}] Procesando sección...`); const resultado = await procesarSeccion(activePage, SECCIONES[i]); resultados.push({ seccion: SECCIONES[i].nombre, modulo: SECCIONES[i].modulo, ...resultado, }); log(""); } // 3. Resumen final log("═══════════════════════════════════════════════════════"); log(" RESUMEN DE DESCARGA"); log("═══════════════════════════════════════════════════════"); log(""); log( ` Secciones procesadas: ${resultados.filter((r) => r.ok).length}/${resultados.length}`, ); log(` Secciones con error: ${resultados.filter((r) => !r.ok).length}`); log(""); for (const r of resultados) { const icono = r.ok ? "✓" : "✗"; const detalles = r.ok ? ` (${r.recursos} recursos)` : ` (${r.error})`; log(` ${icono} ${r.modulo}/${r.seccion}${detalles}`); } log(""); log(" Directorio de descarga:"); log(` ${DOWNLOADS_DIR}`); log(""); log(" Estructura creada:"); log(" secciones/"); log(" Modulo_1_Planificacion/"); for (const sec of SECCIONES.filter( (s) => s.modulo === "Modulo_1_Planificacion", )) { log( ` ${sec.nombre}/ → pagina.html, contenido.md, recursos.json, videos.md, screenshots`, ); } log(" Modulo_2_Entrenamiento/"); for (const sec of SECCIONES.filter( (s) => s.modulo === "Modulo_2_Entrenamiento", )) { log( ` ${sec.nombre}/ → pagina.html, contenido.md, recursos.json, videos.md, screenshots`, ); } log(""); log(" Para descargar videos de Vimeo:"); log( ' yt-dlp --cookies session.json "https://player.vimeo.com/video/{ID}"', ); log(""); // Guardar resultados const reportePath = path.join(DOWNLOADS_DIR, "resultado_descarga.json"); fs.writeFileSync(reportePath, JSON.stringify(resultados, null, 2)); log(` Reporte guardado: resultado_descarga.json`); log(""); log("═══════════════════════════════════════════════════════"); } catch (err) { log(`\n✗ ERROR GENERAL: ${err.message}`); if (err.stack) log(err.stack); try { await screenshot(activePage, "ERROR_general.png"); log(" Screenshot guardado"); } catch (_) {} } finally { if (activeContext && activeContext !== context) { await activePage.close().catch(() => {}); await activeContext.close().catch(() => {}); } else { await page.close().catch(() => {}); await context.close().catch(() => {}); } await browser.close(); log("Navegador cerrado."); } } main();