README.md
August 25, 2026 · View on GitHub
Blob Downloader
Finds every blob: URL a page makes — and every MediaSource
stream, which is the one you actually wanted — and saves it.
No account, no API key, nothing to sign into.
Why
Right-clicking a blob: URL and choosing Save fails, and the reason is that
"blob URL" means two unrelated things.
Sometimes there is a real Blob behind it — a canvas export, a generated file,
a decrypted attachment. That one can be read, but only from inside the page that
made it, and only until the page calls revokeObjectURL, which well-behaved
pages do on the very next line.
Sometimes there is a MediaSource behind it. That is nearly every streaming
video on the web. There is no Blob, fetch() on the URL fails, and the data
does not exist yet — it arrives later, in pieces, as the player appends it. No
amount of copying the URL will ever get you the file.
This handles both.
How it works
Getting into the right world. A page's Blob objects live in the page's own
JavaScript world, and a blob: URL is scoped to the origin that minted it, so an
ordinary content script sees the URL string and nothing behind it. The capture
runs as a world: "MAIN" content script at document_start — before the page's
own scripts can take a reference to URL.createObjectURL and slip past a later
patch. The price is that chrome.* does not exist there at all, so a second,
ordinary content script acts as a bridge.
Real Blobs. URL.createObjectURL is wrapped, and the Blob is kept, not
just its URL. Keeping it is the whole trick: a page that revokes the URL
immediately has destroyed the only public handle to its own data, and our
reference is what is left. Those rows say URL revoked; saving from our own reference.
Streams. MediaSource.prototype.addSourceBuffer and
SourceBuffer.prototype.appendBuffer are wrapped, and every appended segment is
copied — copied, because players reuse one scratch buffer for every fetch, so
keeping the caller's view leaves you holding whichever segment was last. Saving
concatenates them in append order.
Saving. The first design clicked an <a download> in the page, where the
bytes already are, and needed no permissions at all. It works exactly once: a
download a page starts without a user gesture trips Chrome's automatic-downloads
block, and every file after the first is dropped silently. Measured — five
saves, one file on disk, no error anywhere.
So the page hands over a freshly minted URL instead, and chrome.downloads
fetches it. It is exempt from that block, and it resolves a URL belonging to the
page perfectly well. The bytes still never move: nothing is base64'd through the
service worker, and there is no offscreen document.
| File | Role |
|---|---|
src/entrypoints/hook.content.ts | MAIN world: installs the patches, answers for the page |
src/entrypoints/bridge.content.ts | The pipe between the page's world and the extension |
src/entrypoints/background.ts | Aggregates frames, drives chrome.downloads, paints the badge |
src/entrypoints/popup/ | The list, and the Save buttons |
src/lib/blob-registry.ts | The patches and everything they record |
src/lib/segment-store.ts | MediaSource segments, and the cap on them |
src/lib/format.ts | Naming a file for bytes that arrived without a name |
Setup
bun install
bun run build
Load .output/chrome-mv3 unpacked. There is nothing to configure. The toolbar
badge counts what the current page is holding; the popup lists it.
For development, bun run dev launches a browser with the extension loaded.
test/blob-test.html exercises every case — a kept blob, a revoked one, a named
File, a PNG in an <img>, and a hand-driven MediaSource. Serve it over HTTP
rather than opening the file directly, since content scripts do not run on
file:// without the file-access opt-in:
python3 -m http.server 8765 --directory test
Reading what the popup tells you
Rows carry a one-line qualification, and the ones in orange are the ones that affect whether the file will be usable:
- capture began mid-stream — the file may not play. The extension was installed or reloaded while the video was already running, so the initialisation segment — the header that makes every later fragment interpretable — went past before we were watching. Reload the page and play it again.
- track N of M — separate files, mux them with ffmpeg. Streaming video
usually arrives as one SourceBuffer for video and another for audio. They save
as two files:
ffmpeg -i video.mp4 -i audio.mp4 -c copy out.mp4. - hit the size cap; the file ends early. 512 MB per track, discarded from the end so that what you get still starts with its header and plays up to the cut.
- nothing captured yet — press play. A MediaSource exists but the player has not asked for any data. There is nothing to save until it does.
Limits
- Segments are stored in append order, not timeline order. Watch a video through from the start and those are the same thing. Seek backwards and forwards and they are not, and the file will stutter or refuse to play entirely. For a clean capture: fresh page, press play, leave it alone.
- A stream is held in the page's memory while it records, up to 512 MB per track, and retained Blobs up to 1 GB per frame. Past those the row says so rather than failing at the click.
video.srcObject = mediaSourcestreams are captured but harder to label — they never mint a URL, so the<video>is matched by object identity instead.SourceBuffer.changeTypeis not tracked. A stream that switches container mid-playback would produce a file with two different headers spliced together. Codec switches within one container — the common case — are fine.- Blobs are page-lifetime. Reloading the page loses everything it was holding, which is also why the inventory is kept in session storage and not on disk.