1. Introduction: Modern Dynamic Media Delivery

In the early days of web scraping, video and audio files were hosted as static .mp4 or .mp3 files directly referenced in the HTML markup. A simple requests.get() call was sufficient to download full payloads.

Today, dynamic media platforms utilize adaptive HTTP Live Streaming (HLS) and Dynamic Adaptive Streaming over HTTP (DASH). Videos are sliced into thousands of micro-chunks (typically 2 to 6 seconds each) indexed inside .m3u8 or .mpd manifest files. Furthermore, these playlists are requested dynamically through encrypted single-page applications where API tokens are calculated via complex client-side JavaScript, obfuscated WebSocket streams, and canvas fingerprinting challenges.

To build an industrial scraper like KLScrapper, we must architect a three-stage automated pipeline:

  1. Headless Network Interception (Playwright): Execute real browser JavaScript, evaluate DOM triggers, and passively capture media playlist URLs and session cookies.
  2. Asynchronous Chunk Downloader (aiohttp): Fetch video segments concurrently using parallel connection pools and automatic retry policies.
  3. Lossless Stream Muxing (FFmpeg): Combine fragmented audio and video streams into standard MP4 containers in milliseconds without re-encoding.

2. Step 1: Intercepting Dynamic Manifests with Playwright

Instead of attempting to reverse-engineer obfuscated client-side JavaScript signatures that change on every release cycle, we instruct Playwright to launch a headless Chromium browser instance, navigate to the target page, and hook into outgoing network traffic events:

manifest_interceptor.py Python
import asyncio
from playwright.async_api import async_playwright

async def capture_stream_url(page_url: str) -> dict:
    """
    Launches headless Chromium, intercepts network responses, 
    and returns detected HLS/DASH manifest URLs with request headers.
    """
    manifest_data = {}
    
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
        )
        page = await context.new_page()
        
        # Event listener for all network responses
        def on_response(response):
            url = response.url
            if ".m3u8" in url or ".mpd" in url:
                manifest_data["url"] = url
                manifest_data["headers"] = response.request.headers
                
        page.on("response", on_response)
        
        # Navigate and wait until network connections stabilize
        await page.goto(page_url, wait_until="networkidle", timeout=30000)
        await browser.close()
        
    if not manifest_data.get("url"):
        raise ValueError("No video streaming manifest was detected on the page.")
        
    return manifest_data

3. Step 2: Concurrent Segment Downloading with aiohttp & Semaphores

An HLS manifest lists the URIs of hundreds of tiny .ts segments. Downloading these segments synchronously in a standard sequential loop severely bottlenecks network throughput due to TCP handshake and SSL negotiation latency.

By employing Python's asyncio and aiohttp bounded by an asyncio.Semaphore, we can fetch 16 to 32 segments simultaneously, saturating full broadband connection speeds while preventing socket starvation:

segment_downloader.py Python (Asyncio)
import aiohttp
import asyncio

async def download_segment(session: aiohttp.ClientSession, url: str, index: int, sem: asyncio.Semaphore) -> tuple[int, bytes]:
    """Downloads a single segment with retry logic."""
    async with sem:
        for attempt in range(3):
            try:
                async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as response:
                    if response.status == 200:
                        data = await response.read()
                        return (index, data)
            except Exception:
                await asyncio.sleep(1.0 * (attempt + 1))
                
        raise IOError(f"Failed to fetch segment {index} after 3 attempts.")

async def download_all_segments(segment_urls: list[str], max_concurrency: int = 16) -> list[bytes]:
    """Downloads all segments concurrently and returns assembled binary array."""
    sem = asyncio.Semaphore(max_concurrency)
    async with aiohttp.ClientSession() as session:
        tasks = [
            download_segment(session, url, idx, sem) 
            for idx, url in enumerate(segment_urls)
        ]
        results = await asyncio.gather(*tasks)
        
    # Sort segments strictly by original index to ensure video continuity
    results.sort(key=lambda x: x[0])
    return [data for _, data in results]

4. Step 3: Lossless FFmpeg Container Muxing & Bitstream Filtering

Once all video and audio segments are merged into a temporary file on disk, they must be formatted into a standard MP4 container. Many developers mistakenly re-encode streams with ffmpeg -i input.ts -c:v libx264 output.mp4, which consumes 100% CPU time, takes minutes, and introduces re-compression artifacts.

Using stream copying (-c copy) combined with an AAC bitstream filter (-bsf:a aac_adtstoasc) and web streaming optimization flags (-movflags +faststart), FFmpeg merely rewrites the container index without altering underlying H.264 / AAC packet data, completing the entire operation in under 200 milliseconds:

mux_pipeline.py Python (FFmpeg Subprocess)
import subprocess

def remux_to_mp4(input_ts_file: str, final_mp4_path: str):
    """
    Losslessly remuxes a raw MPEG-TS byte stream into a faststart MP4 container.
    Executes in under 250 milliseconds with zero CPU re-encoding overhead.
    """
    cmd = [
        "ffmpeg",
        "-y",
        "-i", input_ts_file,
        "-c", "copy",
        "-bsf:a", "aac_adtstoasc", # Standardize AAC audio bitstream
        "-movflags", "+faststart",  # Relocate moov atom to beginning of file for instant web playback
        final_mp4_path
    ]
    
    result = subprocess.run(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True
    )
    
    if result.returncode != 0:
        raise RuntimeError(f"FFmpeg remuxing error: {result.stderr}")
    return final_mp4_path

5. Error Recovery & Network Throttling Strategies

When scraping hundreds of video files in batch queues, media servers may enforce rate limits or drop TCP connections. To make your crawler resilient:

6. Conclusion

By combining Playwright's transparent browser automation, aiohttp's asynchronous concurrency, and FFmpeg's stream copying engine, developers can build scrapers that effortlessly bypass dynamic JavaScript hurdles and maximize network throughput.

AS

Ataberk Susam

Software Developer & Engineering Student

METU Mechanical Engineering student building desktop tools and automated crawler architectures. Creator of KLScrapper.