1/** 2 * UO Analytics â recolector de sesiones de vÃdeo. 3 * 4 * PRINCIPIOS DE DISEÃO 5 * ==================== 6 * 7 * 1. OBSERVADOR PASIVO. Localiza cualquier <video> del DOM y usa la API HTML5 8 * Media estándar. No conoce, no llama y no depende de `fakelive` ni de ningún 9 * otro plugin. Nunca se engancha por la clase `.fl-video`: esa clase es de 10 * fakelive y podrÃa cambiar en cualquier actualización. 11 * 12 * 2. EL VÃDEO ES EL RELOJ, no `setInterval`. Los navegadores estrangulan los 13 * temporizadores en pestañas ocultas (Chrome llega a congelarlos del todo tras 14 * unos minutos). Alguien que deja el webinar sonando en otra pestaña dejarÃa 15 * de dar señal y lo contarÃamos como abandono. En cambio `timeupdate` sigue 16 * disparando con la pestaña oculta, porque el audio sigue. De ahà la 17 * definición operativa que usa todo el sistema: 18 * 19 * vivo = el vÃdeo está avanzando 20 * 21 * Que además es la definición correcta: si el móvil bloquea la pantalla, el 22 * vÃdeo se pausa y deja de latir, que es exactamente lo que queremos. 23 * 24 * 3. ESTADO COMPLETO, NUNCA INCREMENTOS. Cada envÃo lleva el acumulado entero. 25 * Reenviar es inofensivo y un envÃo perdido lo corrige el siguiente. 26 * 27 * 4. UN SOLO MECANISMO, DOS FUNCIONES. Los envÃos periódicos hacen de respaldo 28 * ante cierres bruscos (`pagehide` no es fiable en iOS Safari) y de señal de 29 * vida para el contador. No existe un latido aparte porque no hace falta. 30 * 31 * DOS TIPOS DE REPRODUCTOR 32 * ======================== 33 * El falso en vivo usa un <video> nativo (hls.js), al que se accede 34 * directamente. Las clases usan el bloque estándar de VideoPress, que es un 35 * iframe de otro dominio: ahà no hay acceso al elemento y la única vÃa es la API 36 * de `postMessage` que publica Automattic. Ambos caminos alimentan exactamente 37 * la misma lógica de medición. 38 */ 39( function () { 40 'use strict'; 41 42 var CFG = window.UOA_CONFIG; 43 if ( ! CFG || ! CFG.url ) { 44 return; 45 } 46 47 // LAS MISMAS EXCLUSIONES QUE EL TRACKER DE PÃGINAS 48 // ------------------------------------------------ 49 // Estas dos comprobaciones existÃan sólo en el tracker web, asà que el ajuste 50 // «respetar Do Not Track» se cumplÃa a medias: no se registraban las páginas 51 // vistas de quien lo activaba, pero sà sus reproducciones de vÃdeo. Prometer 52 // una cosa y hacerla en la mitad del sistema es peor que no prometerla. 53 if ( navigator.webdriver && ! CFG.allowAutomation ) { 54 return; 55 } 56 57 if ( CFG.respectDnt ) { 58 if ( navigator.doNotTrack === '1' || window.doNotTrack === '1' || navigator.globalPrivacyControl ) { 59 return; 60 } 61 } 62 63 // Debe coincidir con UOA_Segments::MAX en PHP. 64 var MAX_SEGMENTS = 1000; 65 66 /** 67 * Segundos de reproducción tras los que se consolida la sesión por primera vez. 68 * 69 * POR QUà EXISTE ESTE PRIMER ENVÃO 70 * -------------------------------- 71 * Los envÃos periódicos van cada 150 segundos. Antes de eso, la sesión sólo 72 * llegaba al servidor si funcionaba el envÃo de cierre â y ése depende de que 73 * el navegador dispare `pagehide` o `visibilitychange` y de que `sendBeacon` 74 * no lo descarte, cosa que en iOS falla con cierta frecuencia. 75 * 76 * Resultado: todo el que viera menos de dos minutos y medio podÃa desaparecer 77 * sin dejar rastro. Y en una emisión con reproducción automática ése es el 78 * grupo más numeroso: los que entran, miran un momento y se van. Justo los que 79 * explican la caÃda inicial de la curva de retención. 80 * 81 * Con esto la sesión queda guardada a los diez segundos y deja de depender de 82 * que el cierre llegue bien. Cuesta una petición más por sesión. 83 */ 84 var FIRST_SEND_AFTER = 10; 85 86 /* --------------------------------------------------------------------- 87 * Utilidades 88 * ------------------------------------------------------------------ */ 89 90 function uuid() { 91 if ( window.crypto && crypto.randomUUID ) { 92 return crypto.randomUUID(); 93 } 94 // Respaldo para navegadores sin randomUUID (Safari < 15.4). 95 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( /[xy]/g, function ( c ) { 96 var r = ( Math.random() * 16 ) | 0; 97 var v = c === 'x' ? r : ( r & 0x3 ) | 0x8; 98 return v.toString( 16 ); 99 } ); 100 } 101 102 function store( key, value ) { 103 try { 104 if ( value === undefined ) { 105 return sessionStorage.getItem( key ); 106 } 107 sessionStorage.setItem( key, value ); 108 return value; 109 } catch ( e ) { 110 // Modo privado o almacenamiento bloqueado: se sigue funcionando en 111 // memoria, la sesión simplemente no sobrevive a una recarga. 112 return null; 113 } 114 } 115 116 function hostOf( url ) { 117 try { 118 return new URL( url, location.href ).hostname.replace( /^www\./, '' ); 119 } catch ( e ) { 120 return ''; 121 } 122 } 123
124 /** 125 * ¿Esta página monta una emisión "falso en vivo"? 126 * 127 * Detección pasiva: se mira si la página cargó el script de fakelive. No se 128 * invoca nada suyo. Los ajustes permiten forzarlo por ID si algún dÃa esta 129 * heurÃstica dejara de valer. 130 * 131 * OJO: esto dice si HAY una emisión en la página, no que TODO lo que haya en 132 * ella lo sea. La clasificación final la decide cada reproductor â ver 133 * `kind()` en NativeMedia y FrameMedia. 134 */ 135 function pageHasLive() { 136 if ( CFG.liveIds && CFG.liveIds.indexOf( CFG.postId ) !== -1 ) { 137 return true; 138 } 139 return !! document.querySelector( 'script[src*="/plugins/fakelive/"]' ); 140 } 141 142 /** 143 * GUID del vÃdeo en VideoPress. 144 * 145 * El <video> tiene un `src` de tipo blob: (MSE), asà que el identificador no 146 * está en el DOM. Sà aparece en las peticiones del manifiesto HLS, que se 147 * leen de la Performance Timeline sin interceptar nada ni tocar hls.js. 148 * 149 * https://videos.files.wordpress.com/p9PK0Zso/archivo.m3u8 150 * ^^^^^^^^ 151 */ 152 function detectVideoId() { 153 try { 154 var entries = performance.getEntriesByType( 'resource' ); 155 for ( var i = entries.length - 1; i >= 0; i-- ) { 156 var m = /videos\.files\.wordpress\.com\/([A-Za-z0-9]{6,16})\//.exec( entries[ i ].name ); 157 if ( m ) { 158 return m[ 1 ]; 159 } 160 } 161 } catch ( e ) {} 162 return ''; 163 } 164 165 function campaign() { 166 var out = []; 167 try { 168 var q = new URLSearchParams( location.search ); 169 [ 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term' ].forEach( function ( k ) { 170 var v = q.get( k ); 171 if ( v ) { 172 out.push( k.replace( 'utm_', '' ) + '=' + v ); 173 } 174 } ); 175 } catch ( e ) {} 176 return out.join( '&' ).slice( 0, 255 ); 177 } 178 179 /** 180 * Codifica el mapa de segmentos: un byte por segmento, en base64. 181 */ 182 function encodeSegments( counts ) { 183 var s = ''; 184 for ( var i = 0; i < counts.length; i++ ) { 185 s += String.fromCharCode( Math.min( 255, counts[ i ] | 0 ) ); 186 } 187 try { 188 return btoa( s ); 189 } catch ( e ) { 190 return ''; 191 } 192 } 193 194 /* --------------------------------------------------------------------- 195 * Coordinación entre pestañas 196 * 197 * Varias pestañas con el mismo vÃdeo son varias sesiones reales de 198 * visualización y todas cuentan para la retención. Pero para el contador de 199 * concurrencia inflarÃan el número, asà que sólo una "lÃder" pide ser 200 * contada; las demás marcan sus envÃos como `quiet`. 201 * ------------------------------------------------------------------ */ 202 203 var Leader = ( function () { 204 var id = uuid(); 205 var leader = true; 206 var channel = null; 207 208 try { 209 if ( window.BroadcastChannel ) { 210 channel = new BroadcastChannel( 'uoa_' + CFG.postId ); 211 channel.onmessage = function ( ev ) { 212 if ( ! ev.data || ! ev.data.id ) { 213 return; 214 } 215 216 // Criterio estable e idéntico en todas las pestañas: gana el 217 // identificador menor. Sin negociación ni estado compartido. 218 if ( ev.data.id < id ) { 219 leader = false;
220 } 221 222 // Y hay que RESPONDER al saludo. Sin esto, la pestaña recién 223 // abierta no oye a nadie y siempre se cree lÃder: la mitad de 224 // las veces habrÃa dos, y el contador de concurrentes contarÃa 225 // dos veces a quien tuviera el vÃdeo en varias pestañas. 226 if ( 'hello' === ev.data.t ) { 227 channel.postMessage( { t: 'here', id: id } ); 228 } 229 }; 230 231 channel.postMessage( { t: 'hello', id: id } ); 232 } 233 } catch ( e ) {} 234 235 return { 236 isLeader: function () { 237 return leader; 238 }, 239 }; 240 } )(); 241 242 /* --------------------------------------------------------------------- 243 * Fuentes de reproducción 244 * 245 * Dos implementaciones con la misma interfaz. La sesión no sabe cuál tiene 246 * delante: sólo consulta tiempo, duración, calidad y velocidad, y recibe 247 * avisos de reproducción, pausa, fin, atasco y error. 248 * ------------------------------------------------------------------ */ 249 250 /** Reproductor <video> del mismo documento (falso en vivo, hls.js). */ 251 function NativeMedia( video ) { 252 this.video = video; 253 this.id = ''; 254 } 255 256 NativeMedia.prototype.time = function () { 257 return this.video.currentTime || 0; 258 }; 259 260 NativeMedia.prototype.duration = function () { 261 return isFinite( this.video.duration ) ? this.video.duration : 0; 262 }; 263 264 NativeMedia.prototype.height = function () { 265 return this.video.videoHeight || 0; 266 }; 267 268 NativeMedia.prototype.rate = function () { 269 return this.video.playbackRate || 1; 270 }; 271 272 /** 273 * El reproductor nativo es la emisión, si la página monta una. 274 * 275 * Si no hay fakelive, un <video> del propio dominio es contenido bajo 276 * demanda: se puede rebobinar, asà que le corresponde el tratamiento de 277 * clase y no el de emisión. 278 */ 279 NativeMedia.prototype.kind = function () { 280 return pageHasLive() ? 'live' : 'vod'; 281 }; 282 283 NativeMedia.prototype.listen = function ( handlers ) { 284 var v = this.video; 285 286 v.addEventListener( 'timeupdate', handlers.tick ); 287 v.addEventListener( 'waiting', handlers.stall ); 288 v.addEventListener( 'playing', handlers.resume ); 289 v.addEventListener( 'error', handlers.error ); 290 v.addEventListener( 'ratechange', handlers.rate ); 291 v.addEventListener( 'ended', handlers.ended ); 292 v.addEventListener( 'pause', handlers.pause ); 293 }; 294 295 /** 296 * Reproductor de VideoPress dentro de un iframe (las clases). 297 * 298 * No hay acceso al elemento: vive en otro dominio. La única vÃa es la API de 299 * `postMessage` que publica Automattic, y el estado se mantiene aquà a partir 300 * de los eventos que emite el reproductor. 301 */ 302 function FrameMedia( id ) { 303 this.id = id || ''; 304 this._time = 0; 305 this._duration = 0; 306 this._rate = 1; 307 this.handlers = null; 308 } 309 310 FrameMedia.prototype.time = function () { 311 return this._time; 312 }; 313 314 FrameMedia.prototype.duration = function () { 315 return this._duration; 316 }; 317 318 FrameMedia.prototype.height = function () { 319 // El iframe no informa de la resolución servida. Es justo lo que se 320 // pierde frente al <video> nativo del falso en vivo. 321 return 0; 322 }; 323 324 FrameMedia.prototype.rate = function () { 325 return this._rate; 326 }; 327 328 /** 329 * Un iframe de VideoPress es SIEMPRE contenido bajo demanda. 330 * 331 * Esto era un fallo real: la clasificación se hacÃa por página, asà que en 332 * `/onboarding/` âdonde conviven el falso en vivo y trece clases de 333 * VideoPressâ las trece se registraban como emisiones. Consecuencias: sin 334 * mapa de segmentos, contaminando la curva de retención de las emisiones, y 335 * con porcentajes engañosos, porque en una clase se puede arrastrar la barra 336 * y la posición máxima deja de significar «hasta dónde llegó». 337 * 338 * La regla correcta es por reproductor: el iframe siempre es clase, esté en 339 * la página que esté. 340 */ 341 FrameMedia.prototype.kind = function () { 342 return 'vod'; 343 }; 344 345 /** 346 * Fija la duración, que en VideoPress llega de fuera del reproductor. 347 * 348 * Se acepta una sola vez y sólo con un valor válido: si más tarde llegara un 349 * dato peor no debe pisar al bueno, porque el mapa de segmentos ya se habrá 350 * dimensionado con el primero. 351 */ 352 FrameMedia.prototype.setDuration = function ( seconds ) { 353 var d = parseFloat( seconds ); 354 355 if ( ! this._duration && isFinite( d ) && d > 0 ) { 356 this._duration = d; 357 } 358 }; 359 360 FrameMedia.prototype.listen = function ( handlers ) { 361 this.handlers = handlers; 362 }; 363 364 /** Procesa un evento del reproductor de VideoPress. */ 365 FrameMedia.prototype.dispatch = function ( data ) {
366 var h = this.handlers; 367 if ( ! h ) { 368 return; 369 } 370 371 switch ( data.event ) { 372 case 'videopress_timeupdate': 373 // `currentTimeMs` PRIMERO: el reproductor manda `currentTime` 374 // truncado a segundos enteros (33) mientras que en milisegundos 375 // va la posición real (33897). Usar el entero perderÃa precisión 376 // justo donde importa, al repartir el visionado en segmentos. 377 if ( typeof data.currentTimeMs === 'number' ) { 378 this._time = data.currentTimeMs / 1000; 379 } else if ( typeof data.currentTime === 'number' ) { 380 this._time = data.currentTime; 381 } 382 h.tick(); 383 break; 384 385 case 'videopress_durationchange': 386 // En la práctica este evento NO llega nunca (comprobado en vivo 387 // contra el reproductor real). Se mantiene por si Automattic lo 388 // activa algún dÃa, pero la duración se obtiene por otra vÃa: 389 // ver `resolveDuration`. 390 if ( typeof data.duration === 'number' ) { 391 this.setDuration( data.duration ); 392 } else if ( typeof data.durationMs === 'number' ) { 393 this.setDuration( data.durationMs / 1000 ); 394 } 395 break; 396 397 case 'videopress_ended': 398 h.ended(); 399 break; 400 401 case 'videopress_pause': 402 h.pause(); 403 break; 404 405 case 'videopress_playing': 406 h.resume(); 407 break; 408 409 case 'videopress_seeking': 410 // El salto se detecta igualmente por el tamaño del avance, pero 411 // avisar permite cortar el tramo continuo con precisión. 412 h.pause(); 413 break; 414 } 415 }; 416 417 /* --------------------------------------------------------------------- 418 * Sesión de visualización 419 * ------------------------------------------------------------------ */ 420 421 function Session( media, index ) { 422 var key = 'uoa_sid_' + CFG.postId + '_' + index; 423 424 this.storeKey = key; 425 this.closedKey = key + '_closed'; 426 427 // UNA SESIÃN CERRADA NO SE PUEDE REUTILIZAR 428 // ----------------------------------------- 429 // El identificador vive en sessionStorage y sobrevive a una recarga. Si 430 // el visitante pulsa F5, el `pagehide` previo ya cerró la fila, y el 431 // servidor rechaza cualquier actualización sobre una sesión cerrada 432 // (`WHERE closed = 0`). Sin rotar el identificador aquÃ, todo lo que vea 433 // después de recargar se descartarÃa en silencio, con respuesta 200. 434 // 435 // Y el sesgo serÃa perverso: quien recarga a mitad de emisión suele ser 436 // justo quien sufre buffering, asà que esas personas figurarÃan como 437 // abandono y contaminarÃan la señal que `buffer_s` existe para detectar. 438 if ( store( this.closedKey ) ) { 439 store( key, uuid() ); 440 store( this.closedKey, '' ); 441 } 442 443 this.media = media; 444 this.sid = store( key ) || store( key, uuid() ) || uuid(); 445 this.kind = media.kind(); 446 this.videoId = media.id || detectVideoId(); 447 448 this.startedAt = Date.now(); 449 this.lastSendAt = Date.now(); 450 this.interval = Math.max( 30, CFG.interval || 150 ); 451 452 // Muestreo: se decide una vez y se mantiene toda la sesión. Afecta sólo a 453 // los envÃos INTERMEDIOS; el envÃo final se manda siempre, de modo que la 454 // curva de retención nunca se degrada por muestrear. 455 this.sampled = ( Math.random() * 100 ) < ( CFG.sample || 100 ); 456 457 this.posMax = 0; 458 this.posLast = 0; 459 this.watched = 0; // Segundos realmente reproducidos (excluye saltos). 460 this.activeMs = 0; // Tiempo de reloj con el vÃdeo en marcha. 461 this.rateMax = 1; 462 this.bufferMs = 0; 463 this.bufferN = 0; 464 this.errors = 0; 465 this.qualityMin = 0; 466 this.qualityLast = 0; 467 this.ctaClicks = 0; 468 this.ctaFirstPos = null; 469 this.ctaHref = ''; 470 // Dos medidas distintas, y confundirlas lleva a conclusiones falsas: 471 // rtt â viaje completo desde el navegador. Incluye la red del 472 // espectador, y estas peticiones salen mientras se descarga el 473 // vÃdeo, asà que compiten por su ancho de banda. Dice cómo de 474 // buena es la conexión de la audiencia. 475 // srv â lo que tardó el servidor, según él mismo. Es lo único que 476 // depende del plugin y lo único comparable con el umbral de 477 // decisión sobre mover la ingesta a otro sitio. 478 this.rtt = 0; 479 this.srv = 0; 480 this.closed = false;
481 this.sending = false; 482 this.beatsSent = 0; 483 this.firstSent = false; 484 485 this._prevTime = null; 486 this._prevWall = null; 487 this._waitingSince = 0; 488 489 // Mapa de segmentos: sólo en las clases, donde el alumno rebobina y la 490 // posición máxima no dice nada sobre lo que ha visto de verdad. 491 this.segments = null; 492 this._lastBucket = -1; 493 494 this.bind(); 495 } 496 497 Session.prototype.bind = function () { 498 var self = this; 499 500 this.media.listen( { 501 tick: function () { 502 self.onTick(); 503 }, 504 505 // Buffering: `waiting` marca el inicio del atasco, `playing` el final. 506 // Esta es la señal que distingue "abandonó porque se aburrió" de 507 // "abandonó porque se le congeló el vÃdeo" â imposible de obtener a 508 // través del iframe de VideoPress. 509 stall: function () { 510 self._waitingSince = Date.now(); 511 }, 512 513 resume: function () { 514 if ( self._waitingSince ) { 515 self.bufferMs += Date.now() - self._waitingSince; 516 self.bufferN++; 517 self._waitingSince = 0; 518 } 519 }, 520 521 error: function () { 522 self.errors++; 523 }, 524 525 rate: function () { 526 var r = self.media.rate(); 527 if ( r > self.rateMax ) { 528 self.rateMax = r; 529 } 530 }, 531 532 // Al terminar el vÃdeo la sesión se cierra: es el dato más limpio que 533 // vamos a tener y conviene consolidarlo antes de que el usuario navegue. 534 ended: function () { 535 self.posLast = self.posMax; 536 self.send( true ); 537 }, 538 539 pause: function () { 540 self.flushActive(); 541 }, 542 } ); 543 }; 544 545 /** 546 * Prepara el mapa de segmentos cuando ya se conoce la duración. 547 * 548 * El número de segmentos se acota: para un vÃdeo corto hay uno por segundo, y 549 * para uno largo cada segmento cubre más tiempo. Asà el envÃo nunca crece 550 * más allá de ~1,4 KB por mucho que dure la clase. 551 */ 552 Session.prototype.ensureSegments = function ( duration ) { 553 if ( this.segments || this.kind !== 'vod' || ! duration || duration <= 0 ) { 554 return; 555 } 556 557 var n = Math.max( 1, Math.min( MAX_SEGMENTS, Math.ceil( duration ) ) ); 558 559 this.segments = new Array( n ); 560 for ( var i = 0; i < n; i++ ) { 561 this.segments[ i ] = 0; 562 } 563 }; 564 565 /** 566 * Núcleo de la medición. Se ejecuta varias veces por segundo mientras el 567 * vÃdeo avanza, asà que se mantiene deliberadamente barato: aritmética
567y 568 * nada más. El envÃo por red sólo se dispara cuando toca. 569 */ 570 Session.prototype.onTick = function () { 571 var t = this.media.time(); 572 var d = this.media.duration(); 573 var now = Date.now(); 574 575 if ( t > this.posMax ) { 576 this.posMax = t; 577 } 578 this.posLast = t; 579 580 var h = this.media.height(); 581 if ( h ) { 582 this.qualityLast = h; 583 if ( ! this.qualityMin || h < this.qualityMin ) { 584 this.qualityMin = h; 585 } 586 } 587 588 this.ensureSegments( d ); 589 590 if ( this.segments && d > 0 ) { 591 // CUIDADO CON LA PRECISIÃN. El cálculo evidente â(t / d) * nâ pierde 592 // segmentos por redondeo de coma flotante: 55/600*600 da 593 // 54.99999999999999 y `floor` lo manda al segmento 54, dejando el 55 594 // a cero para siempre. El efecto son huecos dispersos por todo el 595 // mapa y una cobertura sistemáticamente por debajo de la real, de 596 // modo que un alumno que vio la clase entera aparecerÃa con un 95% y 597 // no llegarÃa al umbral de "completada". 598 // 599 // Multiplicar antes de dividir reduce el error, y el epsilon absorbe 600 // lo que quede. Es nueve órdenes de magnitud menor que un segmento, 601 // asà que nunca puede empujar a uno que no toca. 602 var bucket = Math.min( 603 this.segments.length - 1, 604 Math.max( 0, Math.floor( ( t * this.segments.length ) / d + 1e-9 ) ) 605 ); 606 607 // Se incrementa sólo al ENTRAR en un segmento nuevo. Si no, con 608 // `timeupdate` disparando cuatro veces por segundo, un visionado 609 // normal parecerÃa que se ha repetido decenas de veces. 610 if ( bucket !== this._lastBucket ) { 611 this.segments[ bucket ] = Math.min( 255, this.segments[ bucket ] + 1 ); 612 this._lastBucket = bucket; 613 } 614 } 615 616 if ( this._prevTime !== null ) { 617 var dt = t - this._prevTime; 618 // Sólo cuenta como visionado el avance continuo. Un delta grande es 619 // un salto (o una recuperación de buffer), no tiempo visto: sin este 620 // filtro, la retención serÃa ficción. 621 if ( dt > 0 && dt < 2 ) { 622 this.watched += dt; 623 } 624 this.activeMs += Math.min( now - this._prevWall, 2000 ); 625 } 626 627 this._prevTime = t; 628 this._prevWall = now; 629 630 // Primera consolidación, a los pocos segundos de empezar a ver. A partir 631 // de aquà la sesión ya existe en el servidor y no depende del cierre. 632 if ( ! this.firstSent && this.watched >= FIRST_SEND_AFTER ) { 633 this.firstSent = true; 634 this.send( false, true ); 635 return; 636 } 637 638 if ( now - this.lastSendAt >= this.interval * 1000 ) { 639 this.send( false ); 640 } 641 }; 642 643 Session.prototype.flushActive = function () { 644 this._prevTime = null; 645 this._prevWall = null; 646 }; 647 648 /** 649 * Velocidad de reproducción, deducida de los propios datos. 650 * 651 * POR QUà NO BASTA CON PREGUNTARLE AL REPRODUCTOR 652 * ----------------------------------------------- 653 * El <video> nativo expone `playbackRate` y avisa con `ratechange`, pero **el 654 * iframe de VideoPress no informa de la velocidad en ningún evento** 655 * (comprobado capturando todos los que emite). Asà que en las clases se 656 * quedaba siempre en 1. 657 * 658 * Y eso tenÃa una consecuencia fea: el control de coherencia compara el vÃdeo 659 * visto con el tiempo de reloj transcurrido, asà que un alumno viendo a 1,5x 660 * parecÃa haber visto más vÃdeo del posible y su sesión se marcaba como 661 * incoherente. Gente estudiando rápido, tratada como si falsificara datos. 662 * 663 * Se deduce dividiendo el vÃdeo reproducido entre el tiempo real que costó. 664 * Se usan los acumulados de toda la sesión y no muestras sueltas: promediar 665 * elimina el ruido de que los eventos no lleguen a intervalos exactos. 666 */ 667 Session.prototype.observedRate = function () { 668 if ( this.activeMs < 5000 || this.watched < 5 ) { 669 // Con menos de cinco segundos cualquier división es ruido. 670 return 0; 671 } 672 673 var rate = this.watched / ( this.activeMs / 1000 ); 674 675 // Fuera de este rango no es velocidad de reproducción, es otra cosa: 676 // saltos mal filtrados, un reloj desviado o un payload manipulado. 677 if ( ! isFinite( rate ) || rate < 0.2 || rate > 4.5 ) { 678 return 0; 679 } 680 681 return Math.round( rate * 100 ) / 100; 682 }; 683 684 Session.prototype.coverage = function () { 685 if ( ! this.segments ) { 686 return 0; 687 } 688 689 var seen = 0; 690 for ( var i = 0; i < this.segments.length; i++ ) { 691 if ( this.segments[ i ] > 0 ) { 692 seen++; 693 } 694 } 695 696 return Math.round( ( seen / this.segments.length ) * 10000 ) / 100; 697 }; 698 699 Session.prototype.payload = function ( closing, early ) { 700 if ( ! this.videoId ) { 701 // El manifiesto puede no haberse pedido todavÃa en el primer envÃo. 702 this.videoId = this.media.id || detectVideoId(); 703 } 704 705 var data = { 706 sid: this.sid, 707 kind: this.kind, 708 post_id: CFG.postId, 709 video_id: this.videoId, 710 duration: this.media.duration(), 711 pos_max: this.posMax, 712 pos_last: this.posLast, 713 watched_s: this.watched, 714 wall_s: Math.round( ( Date.now() - this.startedAt ) / 1000 ), 715 active_s: Math.round( this.activeMs / 1000 ), 716 // Se manda la mayor de las dos: la que declaró el reproductor âcuando 717 // puedeâ y la deducida del propio visionado, que es la única vÃa en 718 // las clases de VideoPress. 719 rate_max: Math.max( this.rateMax, this.observedRate() ), 720 buffer_s: this.bufferMs / 1000, 721 buffer_n: this.bufferN,
722 quality_min: this.qualityMin, 723 quality_last: this.qualityLast, 724 errors_n: this.errors, 725 tz: ( Intl.DateTimeFormat().resolvedOptions().timeZone || '' ), 726 lang: ( navigator.language || '' ).slice( 0, 12 ), 727 screen_w: window.screen ? window.screen.width : 0, 728 referrer: document.referrer || '', 729 campaign: campaign(), 730 cta_clicks: this.ctaClicks, 731 cta_first_pos: this.ctaFirstPos, 732 cta_last_href: this.ctaHref, 733 rtt_ms: this.rtt, 734 srv_ms: this.srv, 735 // Una pestaña secundaria guarda su sesión pero no pide ser contada 736 // en el total de concurrentes. Tampoco el primer envÃo: existe para 737 // dejar la sesión guardada cuanto antes, y contarlo inflarÃa el 738 // recuento justo al principio de cada emisión, cuando todo el mundo 739 // acaba de entrar. 740 quiet: early || ! Leader.isLeader(), 741 closed: closing ? 1 : 0, 742 }; 743 744 if ( this.segments ) { 745 data.segments = encodeSegments( this.segments ); 746 data.segment_n = this.segments.length; 747 data.coverage_pct = this.coverage(); 748 } 749 750 return data; 751 }; 752 753 Session.prototype.url = function () { 754 // `sendBeacon` no admite cabeceras, asà que el nonce viaja en la URL. 755 // Sin él, un envÃo de usuario con sesión iniciada llegarÃa como anónimo y 756 // perderÃamos la identidad justo en las clases, que es donde importa. 757 if ( CFG.nonce ) { 758 return CFG.url + ( CFG.url.indexOf( '?' ) === -1 ? '?' : '&' ) + '_wpnonce=' + encodeURIComponent( CFG.nonce ); 759 } 760 return CFG.url; 761 }; 762 763 /** 764 * @param {boolean} closing Cierre definitivo de la sesión. 765 * @param {boolean} early Primera consolidación: ignora el muestreo y no 766 * pide ser contada en el total de concurrentes. 767 */ 768 Session.prototype.send = function ( closing, early ) { 769 if ( this.closed ) { 770 return; 771 } 772 773 // Una sesión que nunca llegó a reproducir nada no se guarda. 774 // 775 // Los reproductores se detectan al cargar la página, asà que sin esta 776 // guarda cada visita a una clase generarÃa una fila vacÃa aunque nadie 777 // pulsara play ây las clases, a diferencia del falso en vivo, no 778 // arrancan solasâ. El dato de «cuántos vieron la página sin darle al 779 // play» no se pierde: sale de cruzar con las páginas vistas, que ya se 780 // registran aparte. 781 if ( this.posMax <= 0 && this.beatsSent === 0 ) { 782 return; 783 } 784 785 // Los envÃos intermedios respetan el muestreo; el de cierre y el primero 786 // nunca. Muestrear el primero dejarÃa fuera precisamente las sesiones 787 // cortas que este envÃo existe para rescatar. 788 if ( ! closing && ! early && ! this.sampled ) { 789 this.lastSendAt = Date.now(); 790 return; 791 } 792 793 if ( ! closing && this.sending ) { 794 return; 795 } 796 797 var body = JSON.stringify( this.payload( closing, early ) ); 798 this.lastSendAt = Date.now(); 799 this.beatsSent++; 800 801 if ( closing ) { 802 this.closed = true; 803 // Se deja constancia de que este identificador queda quemado, para 804 // que una recarga en la misma pestaña arranque con uno nuevo en vez 805 // de escribir contra una fila ya cerrada. 806 store( this.closedKey, '1' ); 807 this.sendBeacon( body ); 808 return; 809 } 810 811 this.sending = true; 812 var self = this; 813 var t0 = ( window.performance && performance.now ) ? performance.now() : 0; 814 815 // `keepalive` permite que el envÃo sobreviva a una navegación en curso. 816 fetch( this.url(), { 817 method: 'POST', 818 headers: { 'Content-Type': 'application/json' }, 819 body: body, 820 credentials: 'same-origin', 821 keepalive: true, 822 } ) 823 .then( function ( r ) { 824 return r.ok ? r.json() : null; 825 } ) 826 .then( function ( json ) { 827 self.sending = false; 828 if ( t0 ) { 829 self.rtt = Math.round( performance.now() - t0 ); 830 } 831 // El servidor informa de lo que tardó él. La diferencia con `rtt` 832 // es red del espectador. 833 if ( json && typeof json.srv === 'number' ) { 834 self.srv = json.srv; 835 } 836 // El servidor manda: si pide espaciar los envÃos, se obedece. 837 // Asà se puede frenar la carga a mitad de emisión sin desplegar. 838 if ( json && json.next ) { 839 self.interval = Math.max( 30, json.next ); 840 } 841 } ) 842 .catch( function () { 843 self.sending = false;
844 // Un fallo de red no se reintenta: el siguiente envÃo lleva el 845 // estado completo y lo corrige solo. 846 } ); 847 }; 848 849 Session.prototype.sendBeacon = function ( body ) { 850 try { 851 if ( navigator.sendBeacon ) { 852 var blob = new Blob( [ body ], { type: 'application/json' } ); 853 if ( navigator.sendBeacon( this.url(), blob ) ) { 854 return; 855 } 856 } 857 } catch ( e ) {} 858 859 // Respaldo si sendBeacon no existe o el navegador lo rechaza. 860 try { 861 fetch( this.url(), { 862 method: 'POST', 863 headers: { 'Content-Type': 'application/json' }, 864 body: body, 865 credentials: 'same-origin', 866 keepalive: true, 867 } ); 868 } catch ( e ) {} 869 }; 870 871 /** 872 * Reabre la medición tras volver de la caché de retroceso del navegador. 873 * 874 * Al pulsar «atrás», la página se restaura tal cual estaba âel script no se 875 * vuelve a ejecutarâ pero su `pagehide` ya cerró la sesión. Sin esto, el 876 * visitante seguirÃa viendo y no se registrarÃa nada. 877 * 878 * Se empieza una fila nueva en vez de continuar la anterior: la cerrada ya 879 * refleja fielmente lo que se vio hasta ese momento. En las clases ni se 880 * nota, porque la cobertura se calcula fusionando todas las sesiones de cada 881 * alumno. 882 */ 883 Session.prototype.reopen = function () { 884 if ( ! this.closed ) { 885 return; 886 } 887 888 store( this.storeKey, uuid() ); 889 store( this.closedKey, '' ); 890 891 this.sid = store( this.storeKey ) || uuid(); 892 this.closed = false; 893 this.sending = false; 894 this.beatsSent = 0; 895 // La fila nueva también necesita su consolidación temprana. 896 this.firstSent = false; 897 // Y sus propias medidas de latencia: arrastrar las de la sesión anterior 898 // las atribuirÃa a envÃos que no ocurrieron. 899 this.rtt = 0; 900 this.srv = 0; 901 902 this.startedAt = Date.now(); 903 this.lastSendAt = Date.now(); 904 905 this.posMax = 0; 906 this.watched = 0; 907 this.activeMs = 0; 908 this.bufferMs = 0; 909 this.bufferN = 0; 910 this.errors = 0; 911 this.ctaClicks = 0; 912 this.ctaFirstPos = null; 913 this.ctaHref = ''; 914 915 this.segments = null; 916 this._lastBucket = -1; 917 this.flushActive(); 918 }; 919 920 Session.prototype.registerCta = function ( href ) { 921 this.ctaClicks++; 922 this.ctaHref = href; 923 if ( this.ctaFirstPos === null ) { 924 this.ctaFirstPos = this.posLast; 925 } 926 // El usuario se marcha ahora mismo: hay que consolidar sin cerrar la 927 // sesión, porque puede volver a la pestaña. 928 this.send( false ); 929 }; 930 931 /* --------------------------------------------------------------------- 932 * Descubrimiento de reproductores y ciclo de vida 933 * ------------------------------------------------------------------ */ 934 935 var sessions = []; 936 var frames = {}; 937 var seen = ( window.WeakSet ? new WeakSet() : null ); 938 939 function attachNative( video ) { 940 if ( seen ) { 941 if ( seen.has( video ) ) { 942 return; 943 } 944 seen.add( video ); 945 } 946 sessions.push( new Session( new NativeMedia( video ), sessions.length ) ); 947 } 948 949 /** 950 * Crea (o recupera) la sesión asociada a un vÃdeo de VideoPress. 951 */ 952 function frameFor( guid ) { 953 if ( ! frames[ guid ] ) { 954 var media = new FrameMedia( guid ); 955 frames[ guid ] = media; 956 sessions.push( new Session( media, sessions.length ) ); 957 resolveDuration( media, guid ); 958 } 959 return frames[ guid ]; 960 } 961 962 /** 963 * Consigue la duración del vÃdeo, que el reproductor no facilita. 964 * 965 * Primero se mira lo que el servidor ya resolvió al renderizar la página âel 966 * caso normal, sin coste algunoâ. Sólo si el vÃdeo no estaba a
966hà se pregunta 967 * al endpoint propio, que a su vez cachea contra la API de VideoPress. 968 * 969 * Si ambas vÃas fallan, la sesión se sigue midiendo: se pierde el mapa de 970 * segmentos y la cobertura, pero el tiempo visto y el punto alcanzado se 971 * registran igual. Degradar es mejor que no medir nada. 972 */ 973 function resolveDuration( media, guid ) { 974 var known = CFG.durations && CFG.durations[ guid ]; 975 976 if ( known ) { 977 media.setDuration( known ); 978 return; 979 } 980 981 if ( ! CFG.metaUrl ) { 982 return; 983 } 984 985 try { 986 fetch( CFG.metaUrl + ( CFG.metaUrl.indexOf( '?' ) === -1 ? '?' : '&' ) + 'guid=' + encodeURIComponent( guid ), { 987 credentials: 'same-origin', 988 } ) 989 .then( function ( r ) { 990 return r.ok ? r.json() : null; 991 } ) 992 .then( function ( json ) { 993 if ( json && json.duration ) { 994 media.setDuration( json.duration ); 995 } 996 } ) 997 .catch( function () {} ); 998 } catch ( e ) {} 999 } 1000 1001 /** 1002 * GUID de VideoPress a partir del `src` de un iframe. 1003 * 1004 * https://videopress.com/embed/q3CJmVxq?controls=1&⦠1005 * ^^^^^^^^ 1006 */ 1007 function guidFromSrc( src ) { 1008 var m = /videopress\.com\/(?:embed|v)\/([A-Za-z0-9]{6,16})/.exec( String( src || '' ) ); 1009 return m ? m[ 1 ] : ''; 1010 } 1011 1012 function scan() { 1013 var list = document.querySelectorAll( 'video' ); 1014 for ( var i = 0; i < list.length; i++ ) { 1015 attachNative( list[ i ] ); 1016 } 1017 1018 // Los iframes se detectan por su `src`, sin esperar a que el reproductor 1019 // emita nada. Asà la duración está resuelta antes del primer `timeupdate` 1020 // y el mapa de segmentos no pierde el arranque del vÃdeo. 1021 var iframes = document.querySelectorAll( 'iframe' ); 1022 for ( var j = 0; j < iframes.length; j++ ) { 1023 var guid = guidFromSrc( iframes[ j ].src ); 1024 if ( guid ) { 1025 frameFor( guid ); 1026 } 1027 } 1028 } 1029 1030 scan(); 1031 1032 // El reproductor se inyecta por JavaScript, asà que puede no existir cuando 1033 // este script se ejecuta. El observador lo recoge cuando aparezca, sin 1034 // sondeos y sin acoplarse a quién lo haya creado. 1035 if ( window.MutationObserver ) { 1036 new MutationObserver( function () { 1037 scan(); 1038 } ).observe( document.documentElement, { childList: true, subtree: true } ); 1039 } 1040 1041 /** 1042 * Puente con el reproductor de VideoPress (las clases). 1043 * 1044 * Se valida SIEMPRE el origen del mensaje. Sin esa comprobación, cualquier 1045 * iframe incrustado en la página âun anuncio, un widget de tercerosâ podrÃa 1046 * inyectar eventos falsos y falsear las estadÃsticas de visionado. 1047 */ 1048 var ALLOWED = /(^|\.)videopress\.com$|(^|\.)video\.wordpress\.com$|(^|\.)v0\.wordpress\.com$/; 1049 1050 window.addEventListener( 'message', function ( ev ) { 1051 var data = ev.data; 1052 1053 if ( ! data || typeof data !== 'object' || typeof data.event !== 'string' ) { 1054 return; 1055 } 1056 1057 if ( data.event.indexOf( 'videopress_' ) !== 0 && data.event !== 'toggle_fullscreen' ) { 1058 return; 1059 } 1060 1061 // El origen es la única defensa real contra eventos fabricados. 1062 var host = hostOf( ev.origin ); 1063 if ( ! host || ! ALLOWED.test( host ) ) { 1064 return; 1065 } 1066 1067 // El campo `id` es el GUID del vÃdeo â verificado contra el reproductor 1068 // realâ, asà que enruta directamente a la sesión creada al detectar el 1069 // iframe. Si el vÃdeo apareció después del escaneo, se crea aquÃ. 1070 var id = String( data.id || 'vp' ); 1071 1072 frameFor( id ).dispatch( data ); 1073 } ); 1074 1075 // Conversión: clic en enlace saliente. Se escucha en fase de captura para 1076 // que se registre aunque otro script detenga la propagación del evento. 1077 document.addEventListener( 1078 'click', 1079 function ( ev ) { 1080 var a = ev.target && ev.target.closest ? ev.target.closest( 'a[href]' ) : null; 1081 if ( ! a ) { 1082 return; 1083 } 1084 1085 var host = hostOf( a.href ); 1086 if ( ! host || host === location.hostname.replace( /^www\./, '' ) ) { 1087 return; 1088 } 1089 1090 var hosts = CFG.ctaHosts || []; 1091 var match = hosts.length === 0; 1092 for ( var i = 0; i < hosts.length && ! match; i++ ) { 1093 if ( host === hosts[ i ] || host.slice( -( hosts[ i ].length + 1 ) ) === '.' + hosts[ i ] ) { 1094 match = true; 1095 } 1096 } 1097 1098 if ( ! match ) { 1099 return; 1100 } 1101 1102 for ( var j = 0; j < sessions.length; j++ ) { 1103 sessions[ j ].registerCta( a.href ); 1104 } 1105 }, 1106 true 1107 ); 1108 1109 function closeAll() { 1110 for ( var i = 0; i < sessions.length; i++ ) { 1111 sessions[ i ].send( true ); 1112 } 1113 } 1114 1115 // `pagehide` es poco fiable en iOS Safari, de ahà que se escuche también 1116 // `visibilitychange`. Y de ahà que existan los envÃos periódicos: son la 1117 // verdadera red de seguridad cuando ninguno de los dos llega a dispararse. 1118 window.addEventListener( 'pagehide', closeAll ); 1119
1120 // Vuelta desde la caché de retroceso: la página se restaura tal cual, sin 1121 // reejecutar el script, pero su `pagehide` ya cerró las sesiones. Hay que 1122 // reabrirlas o el visitante seguirÃa viendo sin que se registre nada. 1123 window.addEventListener( 'pageshow', function ( ev ) { 1124 if ( ! ev.persisted ) { 1125 return; 1126 } 1127 1128 for ( var i = 0; i < sessions.length; i++ ) { 1129 sessions[ i ].reopen(); 1130 } 1131 } ); 1132 1133 document.addEventListener( 'visibilitychange', function () { 1134 if ( document.visibilityState === 'hidden' ) { 1135 for ( var i = 0; i < sessions.length; i++ ) { 1136 var s = sessions[ i ]; 1137 // Ojo: ocultar la pestaña NO cierra la sesión. El audio puede 1138 // seguir sonando y la persona sigue asistiendo. Sólo se 1139 // consolida el estado por si el navegador no vuelve. 1140 if ( ! s.closed ) { 1141 s.send( false ); 1142 } 1143 } 1144 } 1145 } ); 1146} )();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.