Files
rm-KSALDIS-DT/_hist/descargar-curso/completar_offline.mjs
T

837 lines
30 KiB
JavaScript

#!/usr/bin/env node
/**
* COMPLETAR OFFLINE — Versión definitiva
* =======================================
*
* 1. Descarga los 42 videos de Vimeo usando las cookies de sesión
* 2. Los convierte a formato compatible con navegador (MP4)
* 3. Regenera LIBRO_DE_ESTUDIO.html y LIBRO_DE_ESTUDIO.md
* con los videos incrustados como <video> local o referencias
*
* Modo de uso:
* node completar_offline.mjs
*
* Requisitos:
* - yt-dlp instalado (pip install yt-dlp)
* - session.json de Playwright en la carpeta raíz
* - Los HTML descargados (de download_course.mjs)
*
* Archivos de salida:
* descargas/videos_offline/ → videos descargados (.mp4)
* descargas/LIBRO_DE_ESTUDIO.html → libro con videos locales
* descargas/LIBRO_DE_ESTUDIO.md → libro con referencias
*/
import fs from "fs";
import path from "path";
import { execSync, spawn } from "child_process";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DOWNLOADS_DIR = path.join(__dirname, "descargas");
const SECCIONES_DIR = path.join(DOWNLOADS_DIR, "secciones");
const VIDEOS_DIR = path.join(DOWNLOADS_DIR, "videos_offline");
const SESSION_JSON = path.join(__dirname, "session.json");
const COOKIES_TXT = "/tmp/cookies_netscape_voley.txt";
// ── Estructura del curso ──
const MODULOS = [
{
titulo: "Módulo 1 — Planificación",
carpeta: "Modulo_1_Planificacion",
secciones: [
{ carpeta: "Planificacion", titulo: "Planificación" },
{ carpeta: "Preparacion_Atletica", titulo: "Preparación Atlética" },
{ carpeta: "Explicacion_TP", titulo: "Explicación del Trabajo Práctico" },
{ carpeta: "Seguimientos_Equipos", titulo: "Seguimientos de Equipos" },
{ carpeta: "Analisis_Equipos_1", titulo: "Análisis de Equipos 1" },
{ carpeta: "Analisis_Equipos_2", titulo: "Análisis de Equipos 2" },
{ carpeta: "TP_Final_Unidad", titulo: "TP Final de la Unidad" },
],
},
{
titulo: "Módulo 2 — Entrenamiento",
carpeta: "Modulo_2_Entrenamiento",
secciones: [
{ carpeta: "Centrales_y_Oponentes", titulo: "Centrales y Opuestos/as" },
{ carpeta: "Puntas_y_Liberos", titulo: "Puntas y Liberos/as" },
{
carpeta: "Entrenamiento_Armadoras",
titulo: "Entrenamiento de Armadoras",
},
{ carpeta: "Explicacion_TP", titulo: "Explicación del TP" },
],
},
];
// ══════════════════════════════════════════════════════════════
// FUNCIONES AUXILIARES
// ══════════════════════════════════════════════════════════════
function log(msg) {
const ts = new Date().toISOString().replace("T", " ").substring(0, 19);
console.log(`[${ts}] ${msg}`);
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
/**
* Convierte el session.json de Playwright a cookies en formato Netscape
* que yt-dlp pueda usar.
*/
function convertirCookies() {
if (!fs.existsSync(SESSION_JSON)) {
log("⚠ No se encontró session.json. Ejecutá primero download_course.mjs");
return false;
}
const data = JSON.parse(fs.readFileSync(SESSION_JSON, "utf-8"));
const cookies = data.cookies || [];
const lines = ["# Netscape HTTP Cookie File"];
for (const c of cookies) {
const domain = c.domain;
const flag = domain.startsWith(".") ? "TRUE" : "FALSE";
const path = c.path || "/";
const secure = c.secure ? "TRUE" : "FALSE";
const exp = c.expires > 0 ? String(Math.floor(c.expires)) : "1778594896";
const name = c.name;
const value = c.value;
lines.push(
`${domain}\t${flag}\t${path}\t${secure}\t${exp}\t${name}\t${value}`,
);
}
fs.writeFileSync(COOKIES_TXT, lines.join("\n") + "\n");
log(`✓ Cookies convertidas (${cookies.length} cookies)`);
return true;
}
// ══════════════════════════════════════════════════════════════
// PASO 1: EXTRAER TODOS LOS VIDEOS DEL HTML DESCARGADO
// ══════════════════════════════════════════════════════════════
function extraerTodosLosVideos() {
const videos = [];
const vistos = new Set();
for (const modulo of MODULOS) {
for (const seccion of modulo.secciones) {
const htmlPath = path.join(
SECCIONES_DIR,
modulo.carpeta,
seccion.carpeta,
"pagina.html",
);
if (!fs.existsSync(htmlPath)) continue;
const html = fs.readFileSync(htmlPath, "utf-8");
if (html.includes("500 Internal Server Error")) continue;
// Buscar todos los IDs de Vimeo en esta página
const regex = /player\.vimeo\.com\/video\/(\d+)/g;
let match;
while ((match = regex.exec(html)) !== null) {
const id = match[1];
if (vistos.has(id)) continue;
vistos.add(id);
// Extraer el título del tema asociado (buscar hacia atrás)
const before = html.substring(
Math.max(0, match.index - 400),
match.index,
);
const tituloMatch = before.match(/<em>([^<]+)<\/em>/);
const titulo = tituloMatch
? tituloMatch[1]
.trim()
.replace(/[<>:"/\\|?*]+/g, "_")
.substring(0, 80)
: `video_${id}`;
// Extraer la página que embebe el video (para pasarla a yt-dlp)
const paginaURL = `https://voley.onlineeducation.center/es/campus/student/training/e/14197/m/${modulo.carpeta.includes("1") ? "19096" : "19097"}/s/${seccion.carpeta === "Planificacion" ? "34868" : seccion.carpeta === "Preparacion_Atletica" ? "34869" : seccion.carpeta === "Explicacion_TP" && modulo.carpeta.includes("1") ? "34870" : seccion.carpeta === "Seguimientos_Equipos" ? "35523" : seccion.carpeta === "Analisis_Equipos_1" ? "35524" : seccion.carpeta === "Analisis_Equipos_2" ? "35525" : seccion.carpeta === "TP_Final_Unidad" ? "35526" : seccion.carpeta === "Centrales_y_Oponentes" ? "35519" : seccion.carpeta === "Puntas_y_Liberos" ? "35520" : seccion.carpeta === "Entrenamiento_Armadoras" ? "35521" : seccion.carpeta === "Explicacion_TP" && modulo.carpeta.includes("2") ? "35522" : "35519"}/study-resources/list`;
videos.push({
id,
titulo,
pagina: seccion.carpeta,
modulo: modulo.carpeta,
nombreModulo: modulo.titulo,
nombreSeccion: seccion.titulo,
paginaURL,
urlVimeo: `https://player.vimeo.com/video/${id}`,
});
}
}
}
return videos;
}
// ══════════════════════════════════════════════════════════════
// PASO 2: DESCARGA DE VIDEOS
// ══════════════════════════════════════════════════════════════
async function descargarVideos(videos) {
ensureDir(VIDEOS_DIR);
// Verificar yt-dlp
try {
execSync("yt-dlp --version", { stdio: "ignore" });
} catch {
log("✗ yt-dlp no está instalado. Ejecutá: pip install yt-dlp");
return [];
}
if (!fs.existsSync(COOKIES_TXT)) {
if (!convertirCookies()) return [];
}
const descargados = [];
for (let i = 0; i < videos.length; i++) {
const v = videos[i];
const nombreSeguro =
`${v.nombreModulo.replace(/[^a-zA-Z0-9_]/g, "_")}_${v.nombreSeccion.replace(/[^a-zA-Z0-9_]/g, "_")}_${v.titulo.replace(/[^a-zA-Z0-9_]/g, "_")}.mp4`.substring(
0,
120,
);
const rutaSalida = path.join(VIDEOS_DIR, nombreSeguro);
if (fs.existsSync(rutaSalida)) {
log(
` [${i + 1}/${videos.length}] ↺ Ya existe: ${v.titulo.substring(0, 40)}`,
);
descargados.push({ ...v, archivo: nombreSeguro });
continue;
}
log(
` [${i + 1}/${videos.length}] ▶ Descargando: ${v.titulo.substring(0, 50)}...`,
);
try {
// yt-dlp con la página embebedora como URL y cookies
const cmd = [
"yt-dlp",
"--cookies",
COOKIES_TXT,
"-o",
rutaSalida,
"--merge-output-format",
"mp4",
"--no-playlist",
"--progress",
"--print",
"after_move:OK",
v.paginaURL,
].join(" ");
const output = execSync(cmd, {
timeout: 300000, // 5 min por video
stdio: ["ignore", "pipe", "pipe"],
});
// Verificar que se haya descargado
if (fs.existsSync(rutaSalida)) {
const sizeMB = (fs.statSync(rutaSalida).size / 1024 / 1024).toFixed(1);
log(` ✓ ${sizeMB} MB — ${v.titulo.substring(0, 40)}`);
descargados.push({ ...v, archivo: nombreSeguro });
} else {
log(` ⚠ Archivo no encontrado después de descarga`);
}
} catch (err) {
log(` ✗ Error: ${err.message.substring(0, 100)}`);
}
// Pequeña pausa entre descargas
await sleep(1000);
}
return descargados;
}
// ══════════════════════════════════════════════════════════════
// PASO 3: GENERAR LIBRO DE ESTUDIO CON VIDEOS
// ══════════════════════════════════════════════════════════════
function extraerTemas(filePath) {
const temas = [];
try {
if (!fs.existsSync(filePath)) return temas;
const html = fs.readFileSync(filePath, "utf-8");
if (html.includes("500 Internal Server Error")) return temas;
// Extraer tablas de estudio
const tablas = extraerTablas(html);
for (const tabla of tablas) {
const filas = extraerFilas(tabla);
for (const fila of filas) {
if (!fila.includes("<td")) continue;
const tema = procesarFila(fila);
if (tema) temas.push(tema);
}
}
} catch {}
return temas;
}
function extraerTablas(html) {
const tablas = [];
let pos = 0;
while (true) {
const start = html.indexOf(
'<table class="table table-bordered study-resources"',
pos,
);
if (start === -1) break;
const openEnd = html.indexOf(">", start);
if (openEnd === -1) break;
let depth = 1;
let searchPos = openEnd + 1;
while (depth > 0 && searchPos < html.length) {
const nextOpen = html.indexOf("<table", searchPos);
const nextClose = html.indexOf("</table>", searchPos);
if (nextClose === -1) break;
if (nextOpen !== -1 && nextOpen < nextClose) {
depth++;
searchPos = nextOpen + 6;
} else {
depth--;
searchPos = nextClose + 8;
}
}
tablas.push(html.substring(start, searchPos));
pos = searchPos;
}
return tablas;
}
function extraerFilas(tablaHTML) {
const filas = [];
const tbodyStart = tablaHTML.indexOf("<tbody>");
if (tbodyStart === -1) return filas;
const tbody = tablaHTML.substring(tbodyStart);
let pos = 0;
while (true) {
const trStart = tbody.indexOf("<tr", pos);
if (trStart === -1) break;
let depth = 1;
let tableDepth = 0;
let searchPos = trStart + 3;
while (depth > 0 && searchPos < tbody.length) {
const ops = [
{ type: "trO", pos: tbody.indexOf("<tr", searchPos) },
{ type: "trC", pos: tbody.indexOf("</tr>", searchPos) },
{ type: "tableO", pos: tbody.indexOf("<table", searchPos) },
{ type: "tableC", pos: tbody.indexOf("</table>", searchPos) },
].filter((o) => o.pos !== -1);
if (ops.length === 0) break;
const earliest = ops.sort((a, b) => a.pos - b.pos)[0];
switch (earliest.type) {
case "tableO":
tableDepth++;
searchPos = earliest.pos + 6;
break;
case "tableC":
tableDepth = Math.max(0, tableDepth - 1);
searchPos = earliest.pos + 7;
break;
case "trO":
if (tableDepth === 0) depth++;
searchPos = earliest.pos + 3;
break;
case "trC":
if (tableDepth === 0) depth--;
searchPos = earliest.pos + 5;
break;
}
}
filas.push(tbody.substring(trStart, searchPos));
pos = searchPos;
}
return filas;
}
function procesarFila(filaHTML) {
const tituloMatch = filaHTML.match(/<em>([\s\S]*?)<\/em>/i);
const titulo = tituloMatch
? limpiarHTML(tituloMatch[1]).trim()
: "Sin título";
const vimeoMatch = filaHTML.match(/player\.vimeo\.com\/video\/(\d+)/i);
const vimeoID = vimeoMatch ? vimeoMatch[1] : null;
const recursoMatch = filaHTML.match(
/<a[^>]*href="([^"]*)"[^>]*class="btn[^"]*"[^>]*>(Abrir (?:Archivo|Enlace))<\/a>/i,
);
const recursoURL = recursoMatch ? recursoMatch[1] : null;
const recursoTexto = recursoMatch ? recursoMatch[2] : null;
const contenidoMatch = filaHTML.match(
/<div class="redactor-usr-input">([\s\S]*?)<\/div>/i,
);
let contenidoHTML = contenidoMatch ? contenidoMatch[1].trim() : "";
if (!contenidoHTML && !vimeoID && !recursoURL) return null;
const id = titulo
.toLowerCase()
.replace(/[^a-z0-9áéíóúüñ]+/gi, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return { id, titulo, contenidoHTML, vimeoID, recursoURL, recursoTexto };
}
function limpiarHTML(html) {
return html
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, " ")
.trim();
}
function htmlAMarkdown(html) {
if (!html) return "";
let md = html;
md = md.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, "# $1\n\n");
md = md.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, "## $1\n\n");
md = md.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, "### $1\n\n");
md = md.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, "#### $1\n\n");
md = md.replace(/<strong>([\s\S]*?)<\/strong>/gi, "**$1**");
md = md.replace(/<b>([\s\S]*?)<\/b>/gi, "**$1**");
md = md.replace(/<em>([\s\S]*?)<\/em>/gi, "*$1*");
md = md.replace(/<i>([\s\S]*?)<\/i>/gi, "*$1*");
md = md.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, "$1\n\n");
md = md.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, "$1\n");
md = md.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, "$1\n");
md = md.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, "- $1\n");
md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, "> $1\n\n");
md = md.replace(/<table[^>]*>([\s\S]*?)<\/table>/gi, (_, t) =>
tablaAMarkdown(t),
);
md = md.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, "`$1`");
md = md.replace(/<hr[^>]*>/gi, "\n---\n");
// Entities
const ents = {
"&nbsp;": " ",
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&#39;": "'",
"&aacute;": "á",
"&eacute;": "é",
"&iacute;": "í",
"&oacute;": "ó",
"&uacute;": "ú",
"&ntilde;": "ñ",
"&Aacute;": "Á",
"&Eacute;": "É",
"&Iacute;": "Í",
"&Oacute;": "Ó",
"&Uacute;": "Ú",
"&Ntilde;": "Ñ",
"&uuml;": "ü",
"&Uuml;": "Ü",
};
for (const [k, v] of Object.entries(ents))
md = md.replace(new RegExp(k, "g"), v);
md = md.replace(/<[^>]*>/g, "");
md = md.replace(/\n{4,}/g, "\n\n\n");
md = md.replace(/[ \t]+\n/g, "\n");
md = md
.split("\n")
.map((l) => l.trim())
.join("\n");
return md.trim();
}
function tablaAMarkdown(tablaHTML) {
const filas = [];
const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
let match;
while ((match = rowRegex.exec(tablaHTML)) !== null) {
const celdas = [];
const cellRegex = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi;
let cm;
while ((cm = cellRegex.exec(match[1])) !== null) {
const celda = cm[1]
.replace(/<br\s*\/?>/gi, " ")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
celdas.push(celda);
}
if (celdas.length > 0) filas.push(celdas);
}
if (filas.length === 0) return "";
const numCols = Math.max(...filas.map((f) => f.length));
let md = "\n";
while (filas[0].length < numCols) filas[0].push("");
md += "| " + filas[0].join(" | ") + " |\n";
md += "| " + filas[0].map(() => "---").join(" | ") + " |\n";
for (let i = 1; i < filas.length; i++) {
while (filas[i].length < numCols) filas[i].push("");
md += "| " + filas[i].join(" | ") + " |\n";
}
md += "\n";
return md;
}
function generarContenido(videosDescargados) {
// Mapa de video ID → archivo local
const mapaVideos = {};
for (const v of videosDescargados) {
mapaVideos[v.id] = v.archivo;
}
const libros = [];
for (const modulo of MODULOS) {
const seccionesConContenido = [];
for (const seccion of modulo.secciones) {
const htmlPath = path.join(
SECCIONES_DIR,
modulo.carpeta,
seccion.carpeta,
"pagina.html",
);
const temas = extraerTemas(htmlPath);
if (temas.length > 0) {
// Asignar videos descargados a los temas
for (const tema of temas) {
if (tema.vimeoID && mapaVideos[tema.vimeoID]) {
tema.videoLocal = mapaVideos[tema.vimeoID];
}
}
seccionesConContenido.push({ titulo: seccion.titulo, temas });
}
}
if (seccionesConContenido.length > 0) {
libros.push({ titulo: modulo.titulo, secciones: seccionesConContenido });
}
}
return libros;
}
// ══════════════════════════════════════════════════════════════
// GENERAR MARKDOWN
// ══════════════════════════════════════════════════════════════
function generarMarkdown(libros) {
const fecha = new Date().toLocaleDateString("es-AR", {
year: "numeric",
month: "long",
day: "numeric",
});
let totalTemas = 0;
let md = `# Curso de Entrenador Nacional de Vóley
## Material de Estudio Completo
*Generado el ${fecha}*
*Incluye contenido de texto + videos descargados offline*
---
`;
for (const modulo of libros) {
md += `# ${modulo.titulo}\n\n`;
for (const seccion of modulo.secciones) {
md += `## ${seccion.titulo}\n\n`;
for (let t = 0; t < seccion.temas.length; t++) {
const tema = seccion.temas[t];
totalTemas++;
md += `### ${t + 1}. ${tema.titulo}\n\n`;
if (tema.videoLocal) {
md += `🎬 **Video:** \`${tema.videoLocal}\` *(archivo local)*\n\n`;
} else if (tema.vimeoID) {
md += `📹 *Video disponible — ID: ${tema.vimeoID}*\n\n`;
}
if (tema.contenidoHTML) {
md += htmlAMarkdown(tema.contenidoHTML);
md += "\n\n";
}
if (tema.recursoURL) {
const icono = tema.recursoTexto === "Abrir Archivo" ? "📄" : "🔗";
md += `${icono} **${tema.recursoTexto}:** ${tema.titulo} — \`${tema.recursoURL}\`\n\n`;
}
if (t < seccion.temas.length - 1) md += "---\n\n";
}
}
}
md += `\n---\n*Fin del material. ${totalTemas} temas · ${Object.keys(mapaVideos).filter((k) => mapaVideos[k]).length} videos descargados.*\n`;
return md;
}
// ══════════════════════════════════════════════════════════════
// GENERAR HTML (con videos incrustados)
// ══════════════════════════════════════════════════════════════
let mapaVideos = {};
function generarHTML(libros) {
const fecha = new Date().toLocaleDateString("es-AR", {
year: "numeric",
month: "long",
day: "numeric",
});
let totalModulos = libros.length;
let totalSecciones = 0;
let totalTemas = 0;
for (const m of libros) {
totalSecciones += m.secciones.length;
for (const s of m.secciones) totalTemas += s.temas.length;
}
const totalVidsDesc = Object.values(mapaVideos).filter(Boolean).length;
const videoHTML = (tema) => {
if (tema.videoLocal) {
const rutaRelativa = `../videos_offline/${tema.videoLocal}`;
return `<div class="video-wrapper">
<video controls preload="metadata" width="100%">
<source src="${rutaRelativa}" type="video/mp4">
Tu navegador no soporta video HTML5.
</video>
<div class="video-label">🎬 ${tema.titulo}</div>
</div>`;
}
if (tema.vimeoID) {
return `<p class="video-ref">📹 <em>Video no descargado (ID: ${tema.vimeoID})</em></p>`;
}
return "";
};
const css = `
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Georgia, serif; background: #f5f3ef; color: #2c2c2c; line-height: 1.8; font-size: 17px; }
.portada { background: linear-gradient(135deg, #0d2b3e 0%, #1a5276 50%, #2980b9 100%); color: white; padding: 5rem 2rem; text-align: center; min-height: 70vh; display: flex; flex-direction: column; justify-content: center; align-items: center; }
.portada h1 { font-size: 2.8rem; margin-bottom: 0.5rem; font-weight: 700; }
.portada h2 { font-size: 1.4rem; font-weight: 300; opacity: 0.9; }
.portada .stats { margin-top: 2rem; display: flex; gap: 2rem; flex-wrap: wrap; justify-content: center; }
.portada .stat { background: rgba(255,255,255,0.12); padding: 0.6rem 1.2rem; border-radius: 8px; font-size: 0.9rem; }
.indice { background: white; padding: 2rem; max-width: 900px; margin: 0 auto; border-bottom: 1px solid #e0e0e0; }
.indice h2 { color: #0d2b3e; margin-bottom: 1rem; }
.indice ul { list-style: none; }
.indice li { padding: 0.3rem 0; }
.indice a { color: #1a5276; text-decoration: none; border-bottom: 1px solid #ddd; }
.indice a:hover { border-bottom-color: #1a5276; }
.indice .mod-tit { font-weight: 600; margin-top: 0.8rem; color: #0d2b3e; font-size: 1.05rem; }
.contenido { max-width: 900px; margin: 0 auto; padding: 2rem; background: white; }
.contenido h1 { color: #0d2b3e; font-size: 2rem; border-bottom: 3px solid #2980b9; padding-bottom: 0.5rem; margin: 3rem 0 1.5rem; }
.contenido h2 { color: #1a5276; font-size: 1.5rem; margin: 2rem 0 1rem; border-left: 4px solid #2980b9; padding-left: 0.8rem; }
.contenido h3 { color: #0d2b3e; font-size: 1.15rem; margin: 1.5rem 0 0.5rem; }
.contenido p { margin: 0.6rem 0; text-align: justify; }
.contenido ul, .contenido ol { margin: 0.6rem 0; padding-left: 1.5rem; }
.contenido li { margin: 0.3rem 0; }
.contenido strong { color: #0d2b3e; }
.contenido hr { border: none; border-top: 1px solid #e0e0e0; margin: 1.5rem 0; }
.contenido table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.9rem; }
.contenido th, .contenido td { border: 1px solid #ddd; padding: 0.5rem; text-align: left; }
.contenido th { background: #0d2b3e; color: white; }
.contenido tr:nth-child(even) { background: #f9f9f9; }
.contenido blockquote { border-left: 4px solid #2980b9; margin: 1rem 0; padding: 0.5rem 1rem; background: #f0f5fa; font-style: italic; color: #555; }
.contenido .tema-num { background: #2980b9; color: white; display: inline-block; padding: 0.1rem 0.5rem; border-radius: 4px; font-size: 0.8rem; margin-right: 0.5rem; }
.video-wrapper { margin: 1rem 0; background: #000; border-radius: 8px; overflow: hidden; }
.video-wrapper video { display: block; max-height: 500px; }
.video-label { background: #0d2b3e; color: white; padding: 0.4rem 0.8rem; font-size: 0.85rem; text-align: center; }
.video-ref { color: #888; font-style: italic; font-size: 0.9rem; margin: 0.5rem 0; padding: 0.3rem 0.6rem; border-left: 3px solid #ccc; background: #fafafa; }
.recurso-link { display: inline-block; background: #e8f4f8; border: 1px solid #b3d9e8; border-radius: 4px; padding: 0.2rem 0.6rem; font-size: 0.85rem; margin: 0.3rem 0; color: #1a5276; text-decoration: none; }
.recurso-link:hover { background: #d0ebf5; }
.footer { text-align: center; padding: 3rem 2rem; color: #999; font-size: 0.85rem; max-width: 900px; margin: 0 auto; }
@media (max-width: 600px) {
body { font-size: 15px; }
.portada { padding: 2rem 1rem; min-height: auto; }
.portada h1 { font-size: 1.8rem; }
.contenido { padding: 1rem; }
}
`;
// Índice
let idx = '<div class="indice"><h2>📑 Índice de contenidos</h2><ul>';
for (const modulo of libros) {
idx += `<li class="mod-tit">${modulo.titulo}</li><ul>`;
for (let j = 0; j < modulo.secciones.length; j++) {
const s = modulo.secciones[j];
const anchor = `sec-${modulo.titulo.replace(/[^a-zA-Z0-9]/g, "")}-${s.titulo.replace(/[^a-zA-Z0-9]/g, "")}`;
idx += `<li><a href="#${anchor}">${s.titulo} (${s.temas.length} temas)</a></li>`;
}
idx += "</ul>";
}
idx += "</ul></div>";
// Cuerpo
let body = "";
for (const modulo of libros) {
body += `<h1>${modulo.titulo}</h1>\n`;
for (const seccion of modulo.secciones) {
const anchor = `sec-${modulo.titulo.replace(/[^a-zA-Z0-9]/g, "")}-${seccion.titulo.replace(/[^a-zA-Z0-9]/g, "")}`;
body += `<h2 id="${anchor}">${seccion.titulo}</h2>\n`;
for (let t = 0; t < seccion.temas.length; t++) {
const tema = seccion.temas[t];
body += `<h3><span class="tema-num">${t + 1}</span> ${tema.titulo}</h3>\n`;
body += videoHTML(tema);
if (tema.contenidoHTML) {
body += `<div class="estudio-contenido">${tema.contenidoHTML}</div>\n`;
}
if (tema.recursoURL) {
const icono = tema.recursoTexto === "Abrir Archivo" ? "📄" : "🔗";
const urlCompleta = tema.recursoURL.startsWith("http")
? tema.recursoURL
: `https://voley.onlineeducation.center${tema.recursoURL}`;
body += `<p><a class="recurso-link" href="${urlCompleta}" target="_blank">${icono} ${tema.recursoTexto}: ${tema.titulo}</a></p>\n`;
}
if (t < seccion.temas.length - 1) body += "<hr>\n";
}
}
}
// Footer
body += `<p style="text-align:center;color:#999;margin-top:3rem;font-size:0.85rem;">— Fin del material de estudio —</p>`;
return `<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Curso de Vóley — Material de Estudio Completo</title>
<style>${css}</style>
</head>
<body>
<div class="portada">
<h1>🏐 Curso de Entrenador Nacional de Vóley</h1>
<h2>Material de Estudio Completo</h2>
<p>Contenido + videos descargados — 100% offline</p>
<div class="stats">
<div class="stat">📚 ${totalModulos} módulos</div>
<div class="stat">📖 ${totalSecciones} secciones</div>
<div class="stat">🎯 ${totalTemas} temas</div>
<div class="stat">🎬 ${totalVidsDesc} videos</div>
</div>
</div>
${idx}
<div class="contenido">
${body}
</div>
<div class="footer">
<p>Generado el ${fecha} — Online Education Center</p>
<p>Material de estudio 100% offline</p>
</div>
</body>
</html>`;
}
// ══════════════════════════════════════════════════════════════
// MAIN
// ══════════════════════════════════════════════════════════════
async function main() {
console.log("");
console.log("═══════════════════════════════════════════════════");
console.log(" COMPLETAR OFFLINE — Videos + Libro de Estudio");
console.log("═══════════════════════════════════════════════════");
console.log("");
// Paso 1: Extraer videos del HTML
log("Paso 1: Extrayendo videos del contenido descargado...");
const todosLosVideos = extraerTodosLosVideos();
log(` ✓ ${todosLosVideos.length} videos únicos encontrados`);
log("");
// Paso 2: Descargar videos
log("Paso 2: Descargando videos de Vimeo...");
log(" (Puede tomar tiempo dependiendo de tu conexión)");
log("");
const videosDescargados = await descargarVideos(todosLosVideos);
log("");
log(
` ✓ ${videosDescargados.length}/${todosLosVideos.length} videos descargados`,
);
// Construir mapa global
globalThis.mapaVideos = {};
for (const v of videosDescargados) {
globalThis.mapaVideos[v.id] = v.archivo;
}
// Paso 3: Generar libro de estudio
log("");
log("Paso 3: Generando libro de estudio con videos...");
const libros = generarContenido(videosDescargados);
// Markdown
const md = generarMarkdown(libros);
const mdPath = path.join(DOWNLOADS_DIR, "LIBRO_DE_ESTUDIO.md");
fs.writeFileSync(mdPath, md, "utf-8");
log(` ✓ ${mdPath} (${(md.length / 1024).toFixed(0)} KB)`);
// HTML
const html = generarHTML(libros);
const htmlPath = path.join(DOWNLOADS_DIR, "LIBRO_DE_ESTUDIO.html");
fs.writeFileSync(htmlPath, html, "utf-8");
log(` ✓ ${htmlPath} (${(html.length / 1024).toFixed(0)} KB)`);
// Estadísticas
let totalPalabras = 0;
let totalTemas = 0;
for (const m of libros) {
for (const s of m.secciones) {
for (const t of s.temas) {
totalTemas++;
if (t.contenidoHTML) {
totalPalabras += t.contenidoHTML
.replace(/<[^>]*>/g, "")
.split(/\s+/).length;
}
}
}
}
log("");
log("═══════════════════════════════════════════════════");
log(" COMPLETADO");
log("═══════════════════════════════════════════════════");
log("");
log(` 📚 ${libros.length} módulos · ${totalTemas} temas`);
log(` 📝 ~${(totalPalabras / 1000).toFixed(1)}k palabras de contenido`);
log(
` 🎬 ${videosDescargados.length} videos descargados (en videos_offline/)`,
);
log("");
log(" Para abrir el libro de estudio:");
log(` file://${htmlPath}`);
log("");
}
main().catch((err) => {
console.error("Error:", err.message);
process.exit(1);
});