commit 21113bd6badca1e152dc597e1d4d01acbce3d81a Author: Mikkeli Matlock Date: Sat Aug 29 23:57:05 2026 +0900 Capture Bristol Rovers video pass streams for personal time-shifting Verified end to end against a live match: 1080p H.264/AAC MPEG-TS at ~5.2 Mbit/s, recorded while the browser played the same stream. Components: - tampermonkey sniffer hooks fetch/XHR at document-start, captures the manifest URL verbatim and POSTs it to the local catcher. v0.4 also traces the API chain to find which call mints the token. - catcher receives the URL and launches yt-dlp. Dedupes on the Kaltura asset id and checks downloader liveness, so a mid-match page reload is both safe and the recovery path for a dead downloader. - creds stores secrets in the OS vault rather than a gitignored file, and refuses to run on a backend that does not encrypt. Protocol notes are in the README: the stream uses a two-stage token chain (a ~25s hdnts token for the master playlist, a ~24h hdntl token for media), concurrency is unrestricted, and the live window is ~30s with no DVR. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kyt7QHQvLJtQRuiY4wAqKB diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5983c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +recordings/ +logs/ +stream-url.txt +cookies.txt +*.mkv +*.mp4 +*.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a9df81 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# brfc-stream-pirate + +Personal time-shifting archive for Bristol Rovers video pass streams. +Kickoffs land at unfriendly hours in JST; this records them unattended. + +Stream is **plain HLS, no DRM** (StreamAMG behind Akamai). Nothing here breaks +a protection measure -- it replays the same authenticated request the player +makes, using your own paid session. + +## Pieces + +| File | Job | +|---|---| +| `tampermonkey/brfc-stream-sniffer.user.js` | Hooks fetch/XHR in the page, captures the manifest URL verbatim, POSTs it to the catcher | +| `catcher.py` | Listens on 127.0.0.1:8787, hands the URL to yt-dlp, records to `recordings/` | +| `probe.py` | Byte-exact manifest fetch + concurrency test | + +## The token + +Manifest URLs carry an Akamai path token: + + hdntl=exp=~acl=/*~id=~data=hdntl~hmac= + +- `exp` -- ~24h validity +- `acl=/*` -- wildcard over the whole host, not just your match +- `hmac` -- signature over the literal path + +It is a bearer credential. Anyone holding a live URL can pull the stream until +`exp`. Keep it out of pastebins and issue trackers. `stream-url.txt` and +`cookies.txt` are gitignored. + +**Do not re-encode the path.** curl normalises `%2f` to `%2F`, which +invalidates the HMAC. `probe.py` sends the request target verbatim. + +## Use Firefox for watching + +yt-dlp lifts session cookies from the browser. Chrome 127+ on Windows uses +app-bound cookie encryption and extraction fails. Firefox works with no fuss. + + set BRFC_BROWSER=firefox # default + +## Workflow + + pip install yt-dlp # done + python catcher.py # leave running + +Install the userscript in Tampermonkey, open the match page, press play. The +sniffer catches the manifest, the catcher starts yt-dlp. Recording is MPEG-TS +(`--hls-use-mpegts`) so a killed process still leaves a playable file. + +For a 3am kickoff: Task Scheduler opens the match page a few minutes before, +the browser autoplays, the sniffer fires, the catcher records. Nobody is awake. + +## Concurrency check + +An Akamai edge token is a stateless HMAC -- the edge validates signature and +expiry and serves; it holds no session table, so it cannot count viewers. Any +concurrent-stream limit lives in the entitlement API that *issues* tokens, +upstream of the CDN. Confirm rather than assume, while the browser is playing: + + python probe.py --url "" --parallel 3 + +3/3 alongside the playing browser means four concurrent readers of one token +and the edge does not care. + +--- + +## Findings (2026-08-29, live 2300 JST match) + +Verified against a live stream. Recorded 1080p successfully end to end. + +### Two token types, not one + +The player uses a **two-stage token chain**: + +| Token | Where | TTL | Purpose | +|---|---|---|---| +| `hdnts=` | query string, `acl=/*` unencoded | **~25 seconds** | fetch the master playlist | +| `hdntl=` | URL path, `acl=%2f*` encoded | **~24 hours** | media playlists + segments | + +The master response rewrites each variant URL with a 24h `hdntl` token. So the +`hdntl` token is the durable one worth handing to a downloader. Chasing a +`hdnts` URL by hand is pointless -- it dies before you can paste it. + +### Confirmed by experiment + +- **Concurrency is not limited.** Two independent ffmpeg downloads ran at full + bitrate *while Firefox played the same match*. Three concurrent readers of + one token, no complaint from the edge. Akamai token auth is a stateless HMAC + check; it has no viewer counter. +- **No CDN cookies exist.** All 13 relevant cookies are on `bristolrovers.co.uk`; + nothing is scoped to `akamaized.net`. The token in the URL is the whole auth. +- **Stream is 1920x1080 H.264 + AAC ~5.2 Mbit/s** -> roughly 4.7 GB per match. +- **Output is MPEG-TS** even though yt-dlp names it `.mp4`. That is what + `--hls-use-mpegts` buys: a killed process still leaves a playable file. +- Player is **Bitmovin**, embedded on the club site. `0_xm5vrrw6` is a Kaltura + entry id and identifies the match -- the catcher dedupes on it. + +### Gotcha: use ffmpeg/yt-dlp, not a hand-rolled HTTP client + +Python's `http.client` gets a **403 on every request**, with any header set, +including ffmpeg's exact headers, on an unexpired token from the same IP. The +difference is below HTTP (TLS handshake). ffmpeg fetches the same URL fine, so +just use yt-dlp. `probe.py` is diagnostic scaffolding and inherits this 403 -- +do not use it to conclude a token is dead. + +### Firefox, not Chrome + +yt-dlp reads cookies from a running Firefox. Chrome/Edge lock their cookie DB +while running (yt-dlp issue 7271), and an unattended job cannot close the +browser to read them. diff --git a/catcher.py b/catcher.py new file mode 100644 index 0000000..c7e8cfa --- /dev/null +++ b/catcher.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Receive a manifest URL from the Tampermonkey sniffer and record the match. + +Runs on localhost only. The userscript POSTs whatever manifest URL the player +actually requested; we hand it to yt-dlp. + +Reload resilience is the whole point of the liveness check below. A page +reload mid-match is expected (network blips, tab crashes), and it must be +*safe* -- it must not start a duplicate recording, and it must restart a +recording that has died. So we dedupe on "is a downloader for this match +still alive", not "have I seen this match before". +""" +import json +import os +import re +import subprocess +import sys +import threading +from datetime import datetime +from http.server import BaseHTTPRequestHandler, HTTPServer + +HOST, PORT = "127.0.0.1", 8787 +ROOT = os.path.dirname(os.path.abspath(__file__)) +RECORDINGS = os.path.join(ROOT, "recordings") +LOGS = os.path.join(ROOT, "logs") + +# yt-dlp lifts session cookies from here. Firefox is required on Windows: +# Chrome/Edge lock their cookie DB while running (yt-dlp issue 7271). +BROWSER = os.environ.get("BRFC_BROWSER", "firefox") + +# Hard stop, so an unattended run cannot record forever if the stream never +# ends cleanly. Pad past 90 min for half-time, stoppage and post-match. +DURATION_MIN = int(os.environ.get("BRFC_DURATION_MIN", "150")) + +# Kaltura entry id (e.g. 0_xm5vrrw6) identifies the match. Every manifest for +# one game -- master and each variant, each with its own token -- shares it. +ASSET_RE = re.compile(r"/(\d+_[a-z0-9]+)/", re.I) + +_procs = {} # asset key -> Popen of the running downloader +_lock = threading.Lock() + + +def log(msg): + line = f"[{datetime.now():%Y-%m-%d %H:%M:%S}] {msg}" + print(line, flush=True) + os.makedirs(LOGS, exist_ok=True) + with open(os.path.join(LOGS, "catcher.log"), "a", encoding="utf-8") as fh: + fh.write(line + "\n") + + +def asset_key(url): + m = ASSET_RE.search(url) + return m.group(1) if m else url + + +def start_recording(key, url, user_agent, page): + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + out = os.path.join(RECORDINGS, f"brfc-{key}-{stamp}.ts") + os.makedirs(RECORDINGS, exist_ok=True) + + cmd = [ + sys.executable, "-m", "yt_dlp", + "--hls-use-mpegts", # truncated file still plays + "--no-part", + "--cookies-from-browser", BROWSER, + "--user-agent", user_agent or "Mozilla/5.0", + "--referer", page or "https://www.bristolrovers.co.uk/", + # yt-dlp's own retry flags do NOT reach the ffmpeg downloader, which is + # what actually pulls live HLS. These are ffmpeg's own reconnect knobs. + "--downloader-args", + "ffmpeg_i:-reconnect 1 -reconnect_streamed 1 " + "-reconnect_on_network_error 1 -reconnect_delay_max 30", + "--downloader-args", f"ffmpeg_o:-t {DURATION_MIN * 60}", + "-o", out, + url, + ] + logfile = os.path.join(LOGS, f"yt-dlp-{stamp}.log") + fh = open(logfile, "w", encoding="utf-8") + proc = subprocess.Popen(cmd, stdout=fh, stderr=subprocess.STDOUT, cwd=ROOT) + log(f"START {key} -> {os.path.basename(out)} (pid {proc.pid}, cap {DURATION_MIN}min)") + return proc + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path not in ("/stream", "/trace"): + self.send_error(404) + return + n = int(self.headers.get("Content-Length", 0)) + try: + body = json.loads(self.rfile.read(n) or b"{}") + except json.JSONDecodeError: + self.send_error(400, "bad json") + return + + if self.path == "/trace": + self.do_TRACE_post(body) + self.send_response(200) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(b'{"ok":true}') + return + + url = body.get("url", "") + if not url: + self.send_error(400, "no url") + return + + key = asset_key(url) + action = "ignored" + with _lock: + proc = _procs.get(key) + if proc is not None and proc.poll() is None: + action = "already recording" # healthy: reload is a no-op + else: + if proc is not None: + log(f"RESTART {key}: downloader had exited (rc={proc.returncode})") + _procs[key] = start_recording(key, url, body.get("userAgent"), + body.get("page")) + action = "started" + + # Always 200 the browser; the player re-requests manifests constantly. + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(json.dumps({"ok": True, "action": action}).encode()) + + def do_TRACE_post(self, body): + """Discovery mode: append the request/response the page made. + + Used to find which API call mints the stream token, so the chain can + run without a GUI browser. Written as JSONL for easy grepping. + """ + os.makedirs(LOGS, exist_ok=True) + with open(os.path.join(LOGS, "trace.jsonl"), "a", encoding="utf-8") as fh: + fh.write(json.dumps(body, ensure_ascii=False) + chr(10)) + + def do_OPTIONS(self): + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def log_message(self, *args): + pass + + +if __name__ == "__main__": + log(f"catcher listening on http://{HOST}:{PORT}/stream (cookies: {BROWSER})") + HTTPServer((HOST, PORT), Handler).serve_forever() diff --git a/creds.py b/creds.py new file mode 100644 index 0000000..8dc3341 --- /dev/null +++ b/creds.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Credential storage for unattended recording. + +Secrets live in the Windows Credential Manager (DPAPI, keyed to your Windows +account), not in a file. Nothing here can be committed by accident because +nothing here is written to the repo. + + python creds.py --set-login store email + password (prompts) + python creds.py --show show what is stored (never the password) + python creds.py --clear remove everything + +What this does and does not protect against: + + DOES -- plaintext at rest, and the classic "gitignored file gets committed + after a git add -A" accident. + DOES -- readable by another Windows user on this machine. + NOT -- a process already running as you. Credential Manager unlocks with + your login session, so anything running as you can read it. That is + inherent to unattended operation: a job that logs in without you + present must be able to reach the secret without you present. + +Caveat for Task Scheduler: "Run whether user is logged on or not" runs in a +session where DPAPI may not be able to decrypt these entries. Prefer "Run only +when user is logged on" for any task that needs credentials. + +PLATFORM NOTE -- this module is only safe where a real vault backend exists: + + Windows WinVaultKeyring (Credential Manager, DPAPI). Good. + Linux+GUI SecretService (GNOME Keyring / KWallet). Good. + Headless No D-Bus session, no unlocked keyring. `keyring` NOT SAFE. + Linux falls back to a fail or plaintext backend. + +On a headless Pi or server, do NOT use this module. Use systemd-creds, which +encrypts to the host/TPM key and is decrypted only for the service: + + systemd-creds encrypt --name=brfc-pass - /etc/credstore.encrypted/brfc-pass + # then in the unit: LoadCredential=brfc-pass + # and read it from: $CREDENTIALS_DIRECTORY/brfc-pass + +Run `python creds.py --check` on each host before trusting it with a secret. +""" +import argparse +import getpass +import sys + +try: + import keyring +except ImportError: + sys.exit("keyring not installed. Run: pip install keyring") + +SERVICE = "brfc-stream-pirate" + +# Keys held in the vault. +K_USER = "username" +K_PASS = "password" +K_COOKIE = "session-cookie" # cached login session, refreshed periodically + + +def get(key): + return keyring.get_password(SERVICE, key) + + +def put(key, value): + keyring.set_password(SERVICE, key, value) + + +def drop(key): + try: + keyring.delete_password(SERVICE, key) + except keyring.errors.PasswordDeleteError: + pass # already absent + + +def load_login(): + """Return (username, password), or None if not configured yet.""" + user, pw = get(K_USER), get(K_PASS) + return (user, pw) if user and pw else None + + +def load_cookie(): + """Cached session cookie, or None. Callers must handle it being stale.""" + return get(K_COOKIE) + + +def save_cookie(value): + put(K_COOKIE, value) + + +# Backends that do not actually protect anything. `keyring` selects these +# silently on a headless box, so a secret stored there is effectively plaintext. +UNSAFE_BACKENDS = {"PlaintextKeyring", "Keyring", "fail.Keyring", "EnvironCredential"} + + +def backend(): + return keyring.get_keyring().__class__.__name__ + + +def backend_is_safe(): + name = backend() + return name not in UNSAFE_BACKENDS and "fail" not in name.lower() + + +def _mask(s): + if not s: + return "(not set)" + return s[:2] + "*" * max(0, len(s) - 4) + s[-2:] if len(s) > 4 else "*" * len(s) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--set-login", action="store_true", help="store email + password") + g.add_argument("--show", action="store_true", help="show stored username") + g.add_argument("--clear", action="store_true", help="remove all stored secrets") + g.add_argument("--check", action="store_true", + help="report whether this host has a real vault backend") + args = ap.parse_args() + + if args.check: + name = backend() + safe = backend_is_safe() + print(f"backend : {name}") + print(f"verdict : {'SAFE' if safe else 'NOT SAFE -- do not store secrets here'}") + if not safe: + print(" use systemd-creds on this host (see module docstring)") + sys.exit(0 if safe else 1) + + if not backend_is_safe(): + sys.exit(f"refusing to use backend {backend()!r}: it does not encrypt. " + "Run 'python creds.py --check' for guidance.") + + if args.set_login: + user = input("email: ").strip() + if not user: + sys.exit("aborted: empty email") + pw = getpass.getpass("password: ") + if not pw: + sys.exit("aborted: empty password") + put(K_USER, user) + put(K_PASS, pw) + print(f"stored in Windows Credential Manager under '{SERVICE}'") + + elif args.show: + print(f"service : {SERVICE}") + print(f"username: {get(K_USER) or '(not set)'}") + print(f"password: {'(set)' if get(K_PASS) else '(not set)'}") + print(f"cookie : {_mask(get(K_COOKIE))}") + + elif args.clear: + for k in (K_USER, K_PASS, K_COOKIE): + drop(k) + print("cleared") + + +if __name__ == "__main__": + main() diff --git a/probe.py b/probe.py new file mode 100644 index 0000000..c56bcba --- /dev/null +++ b/probe.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Byte-exact manifest fetcher + concurrency test. + +curl normalises percent-escapes (%2f -> %2F), which breaks an Akamai path +token because the HMAC covers the literal path. This sends the request target +verbatim via http.client, and can fire N of them at once to see whether the +edge objects to concurrent readers of one token. + + python probe.py --url "" # single fetch + python probe.py --url "<...>" --parallel 3 # concurrency test + python probe.py --url "<...>" --cookie "name=val" # with a CDN cookie +""" +import argparse +import http.client +import sys +import threading +from urllib.parse import urlsplit + +UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36") + + +def fetch(url, cookie=None, referer="https://www.bristolrovers.co.uk/", tag=""): + parts = urlsplit(url) + # Rebuild the target from the raw split so nothing is re-encoded. + target = parts.path + (("?" + parts.query) if parts.query else "") + conn = http.client.HTTPSConnection(parts.netloc, timeout=15) + try: + conn.putrequest("GET", target, skip_accept_encoding=True) + conn.putheader("User-Agent", UA) + conn.putheader("Accept", "*/*") + conn.putheader("Referer", referer) + conn.putheader("Origin", referer.rstrip("/")) + if cookie: + conn.putheader("Cookie", cookie) + conn.endheaders() + resp = conn.getresponse() + body = resp.read() + print(f"{tag}HTTP {resp.status} {resp.reason} ({len(body)} bytes)") + if resp.status == 200: + text = body.decode("utf-8", "replace") + variants = [l for l in text.splitlines() + if l.startswith("#EXT-X-STREAM-INF") or l.endswith(".m3u8")] + segs = [l for l in text.splitlines() if l and not l.startswith("#")] + if variants: + print(f"{tag} master playlist, {len(variants)//2 or len(variants)} rendition(s):") + for v in variants[:12]: + print(f"{tag} {v}") + else: + print(f"{tag} media playlist, {len(segs)} segment(s)") + for s in segs[:2]: + print(f"{tag} {s[:110]}") + else: + print(f"{tag} {body.decode('utf-8', 'replace')[:200].strip()}") + return resp.status + except Exception as exc: + print(f"{tag}ERROR {type(exc).__name__}: {exc}") + return -1 + finally: + conn.close() + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--url", required=True) + ap.add_argument("--cookie", default=None) + ap.add_argument("--parallel", type=int, default=1, + help="fire N simultaneous fetches to test concurrency") + args = ap.parse_args() + + if args.parallel <= 1: + sys.exit(0 if fetch(args.url, args.cookie) == 200 else 1) + + print(f"firing {args.parallel} simultaneous requests " + f"(keep the browser playing while this runs)\n") + results = {} + start = threading.Barrier(args.parallel) + + def worker(i): + start.wait() # release them together + results[i] = fetch(args.url, args.cookie, tag=f"[{i}] ") + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(args.parallel)] + for t in threads: + t.start() + for t in threads: + t.join() + + ok = sum(1 for v in results.values() if v == 200) + print(f"\n{ok}/{args.parallel} succeeded concurrently" + f"{' -- plus the browser itself' if ok else ''}") + + +if __name__ == "__main__": + main() diff --git a/tampermonkey/brfc-stream-sniffer.user.js b/tampermonkey/brfc-stream-sniffer.user.js new file mode 100644 index 0000000..36efb1e --- /dev/null +++ b/tampermonkey/brfc-stream-sniffer.user.js @@ -0,0 +1,191 @@ +// ==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; + + 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); + }; + + // ---