// ==UserScript== // @name BRFC stream sniffer // @namespace local.brfc.archive // @version 0.4 // @description Capture the manifest URL the player requests, and trace the API call that mints its token. // @match *://*.bristolrovers.co.uk/* // @match *://*.streamamg.com/* // @match *://*.streamamg.co.uk/* // @match *://*.kaltura.com/* // @match *://*.kaltura.co.uk/* // @match *://*.akamaized.net/* // @match *://*.akamaihd.net/* // @noframes false // @run-at document-start // @grant GM_xmlhttpRequest // @connect 127.0.0.1 // ==/UserScript== (function () { 'use strict'; const CATCHER = 'http://127.0.0.1:8787/stream'; const TRACER = 'http://127.0.0.1:8787/trace'; // Discovery mode: record the request/response chain that produces a stream // URL, so the token mint can be replayed without a GUI browser. Set false // once the endpoint is known -- it writes a lot. const TRACE = true; // Start playback without a human. The player needs a click, but the browser // only has to trigger the manifest fetch -- ffmpeg records the real audio // independently, so playing MUTED costs nothing and muted autoplay is // permitted by Firefox policy where audible autoplay is not. const AUTOPLAY = true; const AUTOPLAY_TIMEOUT_MS = 180000; // pre-match margin is long; keep trying const IDLE_OPACITY = 0.063; // 16/255 const MAX_BODY = 20000; // never log a whole media segment console.log('[brfc-sniffer] v0.4 active in frame:', location.href); const seen = new Map(); const isManifest = u => typeof u === 'string' && /\.m3u8(\?|$)/i.test(u); // Media and assets are noise. Everything else is a mint-call candidate. const isNoise = u => /\.(ts|mp4|m4s|aac|jpe?g|png|gif|webp|svg|woff2?|css)(\?|$)/i.test(u); function post(endpoint, payload) { try { GM_xmlhttpRequest({ method: 'POST', url: endpoint, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify(payload), onerror: () => {} }); } catch (e) { /* catcher not running; never break the page */ } } function record(url, via) { let abs = url; if (!/^https?:/i.test(url)) { try { abs = new URL(url, location.href).href; } catch (e) { /* keep raw */ } } if (seen.has(abs)) return; seen.set(abs, { url: abs, via }); console.log('[brfc-sniffer]', via, abs); render(); post(CATCHER, { url: abs, via, page: location.href, userAgent: navigator.userAgent, ts: new Date().toISOString() }); } // The mint call is identified by what it RETURNS, not by its name. Any // response mentioning m3u8 is a candidate, whatever the endpoint is called. function trace(entry, bodyText) { if (!TRACE) return; const mints = !!bodyText && /m3u8/i.test(bodyText); if (!mints && !/api|token|entitle|session|auth|manifest|playback|licen/i.test(entry.url)) return; entry.mintsManifest = mints; entry.body = bodyText ? bodyText.slice(0, MAX_BODY) : null; entry.page = location.href; entry.ts = new Date().toISOString(); if (mints) console.log('[brfc-sniffer] *** MINT CALL ***', entry.method, entry.url); post(TRACER, entry); } // --- fetch ---------------------------------------------------------------- const _fetch = window.fetch; if (_fetch) { window.fetch = function (input, init) { let url = null, method = 'GET'; try { url = (typeof input === 'string') ? input : (input && input.url) || null; method = (init && init.method) || (input && input.method) || 'GET'; if (isManifest(url)) record(url, 'fetch'); } catch (e) { /* ignore */ } const p = _fetch.apply(this, arguments); if (TRACE && url && !isNoise(url)) { p.then(resp => { try { const reqBody = (init && typeof init.body === 'string') ? init.body.slice(0, MAX_BODY) : null; resp.clone().text().then( t => trace({ via: 'fetch', method, url, status: resp.status, reqBody }, t), () => {} ); } catch (e) { /* ignore */ } }, () => {}); } return p; }; } // --- XHR ------------------------------------------------------------------ const _open = XMLHttpRequest.prototype.open; const _send = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url) { try { this.__brfc = { method: method, url: url }; if (isManifest(url)) record(url, 'xhr'); } catch (e) { /* ignore */ } return _open.apply(this, arguments); }; XMLHttpRequest.prototype.send = function (body) { try { const info = this.__brfc; if (TRACE && info && info.url && !isNoise(info.url)) { const reqBody = (typeof body === 'string') ? body.slice(0, MAX_BODY) : null; this.addEventListener('load', () => { let text = null; try { if (this.responseType === '' || this.responseType === 'text') { text = this.responseText; } } catch (e) { /* binary response */ } trace({ via: 'xhr', method: info.method, url: info.url, status: this.status, reqBody: reqBody }, text); }); } } catch (e) { /* ignore */ } return _send.apply(this, arguments); }; // ---