Files
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

204 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""Drive the player with Playwright and capture the traffic that mints a token.
Purpose: find which request produces a stream URL, so the mint can eventually
run without a GUI. Playwright sees request AND response bodies natively, so
this replaces the userscript's hooks with something far less fragile.
No password is needed. Your existing Firefox session cookies are injected into
a fresh browser context, so this rides the login you already have.
python discover.py --url "<match page url>"
python discover.py --url "..." --headless
Ports to Camoufox on halogen by swapping the launcher: Camoufox speaks the
Playwright protocol, so the flow below is unchanged.
"""
import argparse
import json
import os
import re
import sys
from datetime import datetime
ROOT = os.path.dirname(os.path.abspath(__file__))
LOGS = os.path.join(ROOT, "logs")
TRACE = os.path.join(LOGS, "pw-trace.jsonl")
# Only these domains' cookies are handed to the browser. Everything else in
# the Firefox jar is irrelevant here and should not be copied around.
COOKIE_DOMAINS = ("bristolrovers", "streamamg", "kaltura", "akamaized")
# Bodies we never want to read: media segments would be dumped into the log.
SKIP_BODY = (".ts", ".mp4", ".m4s", ".aac", ".jpg", ".jpeg", ".png", ".gif",
".webp", ".svg", ".woff", ".woff2", ".css", ".ico")
MAX_BODY = 20000
def firefox_cookies():
"""Lift the live session from Firefox. Requires no password."""
from yt_dlp.cookies import extract_cookies_from_browser
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
jar = extract_cookies_from_browser("firefox")
out = []
for c in jar:
dom = (c.domain or "")
if not any(k in dom.lower() for k in COOKIE_DOMAINS):
continue
out.append({
"name": c.name,
"value": c.value or "",
"domain": dom,
"path": c.path or "/",
"expires": float(c.expires) if c.expires else -1,
"httpOnly": False,
"secure": bool(c.secure),
"sameSite": "Lax",
})
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--url", required=True, help="match page URL")
ap.add_argument("--headless", action="store_true",
help="run without a window (what halogen will do)")
ap.add_argument("--seconds", type=int, default=90,
help="how long to watch after load")
args = ap.parse_args()
try:
from playwright.sync_api import sync_playwright
except ImportError:
sys.exit("playwright not installed: pip install playwright "
"&& python -m playwright install firefox")
os.makedirs(LOGS, exist_ok=True)
cookies = firefox_cookies()
print(f"injecting {len(cookies)} session cookies")
manifests, mints, jwts, seen = [], [], [], set()
fh = open(TRACE, "a", encoding="utf-8")
def write(rec):
rec["ts"] = datetime.now().isoformat()
fh.write(json.dumps(rec, ensure_ascii=False) + chr(10))
fh.flush()
with sync_playwright() as p:
browser = p.firefox.launch(headless=args.headless)
ctx = browser.new_context(
user_agent=("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) "
"Gecko/20100101 Firefox/131.0"),
viewport={"width": 1440, "height": 900},
)
if cookies:
try:
ctx.add_cookies(cookies)
except Exception as exc:
print(f"cookie injection failed: {exc}")
page = ctx.new_page()
def on_response(resp):
url = resp.url
if url in seen:
return
low = url.split("?")[0].lower()
if ".m3u8" in low:
seen.add(url)
manifests.append(url)
print(f" manifest: {url[:120]}")
write({"kind": "manifest", "url": url, "status": resp.status})
return
if any(low.endswith(e) for e in SKIP_BODY):
return
try:
body = resp.text()
except Exception:
return
if not body:
return
# The JWT that authorises the mint is issued somewhere on the club
# site off the session cookie. Detect it the same way: by output.
# A JWT is three base64 segments starting with a b64 '{"alg"'.
if re.search(r"eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.", body):
seen.add(url)
req = resp.request
rec = {
"kind": "JWT-ISSUER",
"method": req.method,
"url": url,
"status": resp.status,
"reqHeaders": dict(req.headers),
"reqBody": (req.post_data or None),
"body": body[:MAX_BODY],
}
jwts.append(rec)
write(rec)
print(f" *** JWT ISSUER *** {req.method} {url[:100]}")
return
# The mint call is whatever RETURNS a manifest URL, whatever it is
# named. Detect by output, not by guessing endpoint names.
if "m3u8" in body.lower():
seen.add(url)
req = resp.request
rec = {
"kind": "MINT",
"method": req.method,
"url": url,
"status": resp.status,
"reqHeaders": dict(req.headers),
"reqBody": (req.post_data or None),
"body": body[:MAX_BODY],
}
mints.append(rec)
write(rec)
print(f" *** MINT *** {req.method} {url[:110]}")
page.on("response", on_response)
print(f"loading {args.url}")
page.goto(args.url, wait_until="domcontentloaded", timeout=60000)
# The player needs a click. Muted playback is enough to trigger the
# manifest fetch, and muted autoplay is not blocked.
page.wait_for_timeout(5000)
for sel in (".bmpui-ui-hugeplaybacktogglebutton",
".bmpui-ui-playbacktogglebutton",
"button[aria-label*='play' i]"):
try:
el = page.query_selector(sel)
if el and el.is_visible():
el.click()
print(f"clicked {sel}")
break
except Exception:
pass
try:
page.evaluate("() => { const v = document.querySelector('video');"
" if (v) { v.muted = true; v.play(); } }")
except Exception:
pass
print(f"watching for {args.seconds}s ...")
page.wait_for_timeout(args.seconds * 1000)
browser.close()
fh.close()
print(f"\nmanifests seen : {len(manifests)}")
print(f"mint calls : {len(mints)}")
for m in mints:
print(f"\n {m['method']} {m['url']}")
print(f" status {m['status']} reqBody={str(m['reqBody'])[:200]}")
print(f"\nfull trace -> {TRACE}")
if __name__ == "__main__":
main()