PageSourceSearch

https://universidad.online/wp-content/plugins/uo-analytics/assets/js/collector.js?ver=1.9.1

js universidad.online collected 2026-10-04 12:38:49 UTC 36,871 bytes, 1,146 lines download raw bytes

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.