Files
brfc-stream-pirate/tampermonkey/brfc-stream-sniffer.user.js
T
Mikkeli Matlock ce92e55255 Map the full token chain and prove it needs no browser
Playwright discovery against the live feed found every link, then the whole
chain was replayed in plain urllib with no browser at all:

  SSESS cookie -> GET /oauth/browser-token          -> JWT (90 min)
  JWT + x-api-key -> GET /v1/entry/{id}             -> hdnts master URL (~25s)
  master -> hdntl token (24h) -> ffmpeg

Both auth hops live off the Akamai CDN, so plain Python reaches them; ffmpeg
handles the CDN, which is the only host Python gets 403s from. Nothing needs
a fingerprint forged and Camoufox is now a fallback rather than the plan.

Two assumptions this disproved:
- The play click is not part of auth. Manifests arrive before any click, so
  a headless run needs no interaction to mint a token.
- The 90 minute JWT does not constrain a 2 hour recording. It is spent before
  ffmpeg starts; the 24h hdntl token carries the match.

discover.py drives the page with Playwright and identifies the mint and token
endpoints by what they RETURN rather than by guessing endpoint names, so it
survives the endpoints being renamed. It rides existing Firefox cookies and
needs no password. Verified working with --headless.

Sniffer v0.5 adds muted autoplay: the browser only has to trigger the manifest
fetch, ffmpeg records real audio separately, and muted autoplay is permitted
where audible is not. It stands down once playback starts, because the control
is a toggle and clicking twice would pause the recording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kyt7QHQvLJtQRuiY4wAqKB
2026-09-01 11:45:04 +09:00

262 lines
9.6 KiB
JavaScript

// ==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);
};
// --- <video src> ----------------------------------------------------------
const _setAttr = Element.prototype.setAttribute;
Element.prototype.setAttribute = function (name, value) {
try {
if (/^src$/i.test(name) && isManifest(value)) record(value, 'attr');
} catch (e) { /* ignore */ }
return _setAttr.apply(this, arguments);
};
// --- panel ----------------------------------------------------------------
let panel;
function render() {
if (window.top !== window.self) return;
if (!panel) {
panel = document.createElement('div');
panel.style.cssText = [
'position:fixed', 'right:12px', 'bottom:12px', 'z-index:2147483647',
'max-width:min(560px,90vw)', 'max-height:40vh', 'overflow:auto',
'background:#101418', 'color:#e6edf3',
'font:12px/1.45 ui-monospace,Menlo,Consolas,monospace',
'border:1px solid #2b333c', 'border-radius:8px', 'padding:10px 12px',
'box-shadow:0 8px 24px rgba(0,0,0,.45)',
'opacity:' + IDLE_OPACITY,
'transition:opacity .18s ease'
].join(';');
panel.addEventListener('mouseenter', () => { panel.style.opacity = '1'; });
panel.addEventListener('mouseleave', () => { panel.style.opacity = String(IDLE_OPACITY); });
(document.body || document.documentElement).appendChild(panel);
}
panel.innerHTML = '<b style="color:#5ac8fa">BRFC sniffer v0.4</b> '
+ seen.size + ' manifest(s)'
+ (TRACE ? ' <span style="color:#e3b341">[tracing]</span>' : '') + '<br>';
for (const rec of seen.values()) {
const row = document.createElement('div');
row.style.cssText = 'margin-top:8px;word-break:break-all;'
+ 'border-top:1px solid #2b333c;padding-top:6px';
const btn = document.createElement('button');
btn.textContent = 'copy';
btn.style.cssText = 'margin-right:8px;cursor:pointer;background:#1f6feb;'
+ 'color:#fff;border:0;border-radius:4px;padding:2px 8px';
btn.onclick = () => navigator.clipboard.writeText(rec.url).then(() => {
btn.textContent = 'copied';
setTimeout(() => { btn.textContent = 'copy'; }, 1200);
});
row.appendChild(btn);
row.appendChild(document.createTextNode('[' + rec.via + '] ' + rec.url));
panel.appendChild(row);
}
}
// --- autoplay -------------------------------------------------------------
// Runs in whichever frame owns the <video>, which may be the player iframe.
function findVideo() {
const v = document.querySelector('video');
return (v && v.isConnected) ? v : null;
}
function clickPlayButton() {
const sels = [
'.bmpui-ui-hugeplaybacktogglebutton', // Bitmovin centre button
'.bmpui-ui-playbacktogglebutton', // Bitmovin control bar
'button[aria-label*="play" i]',
'[class*="play-button"]', '[class*="playButton"]'
];
for (const s of sels) {
const el = document.querySelector(s);
if (el && el.offsetParent !== null) { // must be visible to be real
el.click();
console.log('[brfc-sniffer] autoplay: clicked', s);
return true;
}
}
return false;
}
function startAutoplay() {
if (!AUTOPLAY) return;
const deadline = Date.now() + AUTOPLAY_TIMEOUT_MS;
const timer = setInterval(() => {
const v = findVideo();
// Done once something is actually playing. Critically, we must stop
// here: the play control is a TOGGLE, so clicking it again would pause
// playback and silently end the recording.
if (v && !v.paused && !v.ended && v.readyState > 2) {
console.log('[brfc-sniffer] autoplay: playing, standing down');
clearInterval(timer);
return;
}
if (Date.now() > deadline) {
console.log('[brfc-sniffer] autoplay: gave up after',
AUTOPLAY_TIMEOUT_MS / 1000, 's');
clearInterval(timer);
return;
}
if (v) {
v.muted = true; // audible autoplay is blocked; muted is not
const p = v.play();
if (p && p.catch) p.catch(() => { clickPlayButton(); });
} else {
clickPlayButton(); // player chrome may exist before <video>
}
}, 2000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startAutoplay);
} else {
startAutoplay();
}
})();