837 lines
30 KiB
JavaScript
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(/ /g, " ")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/'/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 = {
|
|
" ": " ",
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
""": '"',
|
|
"'": "'",
|
|
"á": "á",
|
|
"é": "é",
|
|
"í": "í",
|
|
"ó": "ó",
|
|
"ú": "ú",
|
|
"ñ": "ñ",
|
|
"Á": "Á",
|
|
"É": "É",
|
|
"Í": "Í",
|
|
"Ó": "Ó",
|
|
"Ú": "Ú",
|
|
"Ñ": "Ñ",
|
|
"ü": "ü",
|
|
"Ü": "Ü",
|
|
};
|
|
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(/ /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);
|
|
});
|