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
This commit is contained in:
+203
@@ -0,0 +1,203 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user