The feature was supposed to be the easy one. NotebookLM generates flashcards. My extension reads them off the page. You export them to Anki. I'd already built the hard-looking part — the export — so I figured reading the cards was an afternoon of work.
It took two days, and by the end I was intercepting a private Google API and peeling a 1.5MB blob apart by hand.
Here's how it started. I opened the flashcard view, hit F12, ran my scraper. Zero cards. Every selector came back empty — not wrong text, nothing. So I did the honest thing and actually looked at the DOM instead of trusting what I thought was there.
The cards render inside an iframe. And the iframe is served from a different domain — scf.usercontent.goog, if you want the specifics. That's the same-origin policy doing exactly what it's built to do: my code runs on notebooklm.google.com, and the browser flatly refuses to let it reach into a frame from another origin. I could see the cards with my own eyes. My script was standing on the wrong side of a wall it isn't allowed to climb.
Two ways over that wall. One: inject a script directly into the iframe and read it from the inside. Two: forget the iframe entirely and catch the data on the network, before it ever gets there. Because the main page has to fetch that flashcard data from somewhere to build the iframe in the first place.
I went looking for the network call. Opened the Network tab, regenerated the cards, and watched. There it was: a request to a thing called batchexecute — Google's internal RPC endpoint — with rpcid=v9rmvd. The response was 1,596,933 bytes. One and a half megabytes for 56 flashcards. Somewhere in there was my data.
Then the fun part: that response is not JSON you can just parse. It's Google's chunked format — a )]}' safety prefix, then alternating lines of "here's a length" and "here's a blob." Parse the whole thing and it throws immediately. You have to walk it line by line, find the one line that mentions v9rmvd, and parse just that.
Inside that line is another JSON string. Parse that, and you get a nested array. Buried in the array is a full HTML document — the entire iframe page, escaped, doctype and all. And inside that HTML, finally, is an attribute called data-app-data holding the thing I'd been chasing for two days:
{ "flashcards": [ { "f": "front text", "b": "back text", "c": 1 }, ... ] }
Clean JSON. f is the front, b is the back — which map perfectly onto Anki's own two fields — and c marks the card type (1 for normal, 2 for cloze/fill-in-the-blank). Fifty-six of them, in order, nothing truncated.
One last trap before it worked. A Chrome extension's content script lives in an "isolated world" — it can see the page's DOM but not the page's own JavaScript, which means it can't see the page's network calls either. To hook the XHR that carries the flashcards, I had to inject a second script into the page's MAIN world, catch the response there, and hand it back across the boundary with postMessage. Miss that detail and you hook nothing and can't figure out why.
What I keep taking away from this: the wall was real, but it only guarded one door. The same-origin policy stops you reading the iframe — it does nothing to stop you reading the request that feeds the iframe. Most "you can't get there" walls in browser land are like that. There's usually a side entrance, and it's usually the network tab.
The honest catch is that I'm now parsing an undocumented Google format held together with an rpcid I don't control. The day they rename v9rmvd, this breaks. So I wrapped every step in a fail-to-empty and wired it to my telemetry, so I hear about it from a dashboard instead of an angry email the week before someone's exam. Building on a platform you don't own means picking which fragile thing you'd rather babysit. I picked this one on purpose — a clean JSON payload beats scraping rendered HTML that redesigns itself every quarter.
Ever had to go around the front door like this — where the official "no" had an unofficial "sure, over here"? I'd love to hear the weirdest one you've pulled off.
— building NotebookBloom in public, #6
Top comments (8)
Wiring the fail-to-empty into telemetry is the part most people skip, so nice call. One question on it: does an empty parse and a real "user has zero cards" look the same to your dashboard? If they do, the day v9rmvd renames you'd get a quiet flatline that reads as low usage instead of a break, and that's the worst kind to catch late.
Yeah, this one lands, and it's a little uncomfortable — you're describing my telemetry catching the exact disease the rest of the post is about. If empty-because-broken and empty-because-legit collapse into the same number, I've just built a quieter version of the original bug and pointed a dashboard at it.
The thing that saves me is that the two cases actually diverge earlier in the pipeline, if I bother to look. A real "zero cards" user still gets a well-formed
v9rmvdresponse — the request fires, the chunked format parses, I find the marker line, and theflashcardsarray is just legitimately empty. A rename breaks it upstream of that: the request 404s, or the response parses but no line mentionsv9rmvd, or that line's shape changed. Same final count, completely different path to get there.So the fix is to stop reporting one "card count" event and instead emit a little breadcrumb at each stage — request ok, chunk parsed, marker found, N cards — and alert on where the trail stops, not on the final zero. Legit-empty walks the whole trail and ends at 0. A break dies at step 2 and never reaches "marker found," which reads as a cliff, not a flatline. Honestly I hadn't drawn that line sharply enough and was one refactor away from exactly the blind spot you're describing. Fixing it. Great catch.
The isolated-world trap is the right thing to call out, and it's worth knowing you can declare it now instead of injecting it:
content_scriptstakesworld: "MAIN"straight in the manifest (docs), so the<script>-tag dance goes away (you stillpostMessageback to your isolated script). The part that actually matters for an XHR hook is pairing it withrun_at: "document_start", which injects while the DOM is still loading, ahead of the page's own scripts. A hand-injected hook has to beat the page to its first request; a declared one is already installed.The
run_at: "document_start"pairing is the part I wish I'd known two days earlier, because it names a bug I actually hit and papered over instead of fixing. With the hand-injected version, on a cold load my hook occasionally lost a race — the page fired its firstbatchexecutebefore my MAIN-world script was installed, so I caught nothing and the cards came back empty maybe one load in ten. My "fix" at the time was to also read from the DOM as a fallback, which is exactly the fragile thing I was trying to get away from. A declared hook that's already sitting there atdocument_startjust deletes that race instead of me betting against it.And yeah, killing the
<script>-tag dance is worth it on its own — that injection step was easily the jankiest part of the whole thing, and it's the bit most likely to trip a stricter CSP down the road.postMessageback to the isolated world I'll happily keep; that boundary's honest.Genuinely useful comment — this is going in as an actual refactor, not a someday-maybe. Thanks for the specifics instead of just "you could do it cleaner."
This was a really satisfying read. I especially liked the shift from trying to access the iframe itself to tracing the request that feeds it. It made the whole debugging process feel very clear and logical.
Thank you — that means a lot. I'll let you in on the trick, though: it reads clean because I wrote it after I knew the answer. The actual two days were nothing like a straight line — a lot of staring at empty selectors, one wrong guess about the iframe, and a fair amount of "why is this 1.5MB and where is my data." Editing is where the logic showed up; the doing was mostly flailing. I think that's true of most debugging stories that read well, which is maybe its own small comfort.
Ha, the classic "should be an afternoon" that turns into two days of peeling apart undocumented Google blobs. I felt that in my bones—especially the moment when you finally spot the one request in the Network tab and think "gotcha." That dopamine hit is real.
What I appreciate most about this is the honest acknowledgment that you are now married to an rpcid you do not control. A lot of developers would stop at "it works" and call it done, but you built the telemetry and the graceful fallback before shipping. That is the kind of thinking that separates a weekend hack from something you can actually rely on—even if just for a while.
One thing that crossed my mind while reading: if the flashcard data is relatively static for a given user session, you could cache that parsed payload locally after the first successful fetch. That way, even if Google tweaks v9rmvd or the chunked format shifts slightly, your extension still serves the cached version for existing users while you push an update. Not a permanent fix, but it buys you a window instead of breaking instantly for everyone.
To your question about going around the front door—I once had to extract real-time stock data from a third-party dashboard that explicitly blocked all API access. Turned out they were pushing updates through a Server-Sent Events endpoint that was completely unauthenticated and wide open. I felt like I had found a backdoor that was not even locked. The moment I saw that stream of events in the Network tab, I just stared at the screen for a solid minute wondering if I should use it or report it. I used it—carefully, and with a polite note to their team later.
Anyway, this was a fantastic read. Your NotebookBloom series is turning into something genuinely valuable for anyone who builds extensions on top of platforms they do not own. Keep these coming.
That SSE story is the exact shape of the thing — the official "no" with an unlocked side door sitting right next to it, and the weird minute where you're not sure if you're a hacker or a QA tester who just found their bug. Using it carefully and then sending the polite note is the right call; you basically did their security team a favor for free.
On caching the parsed payload — you're right that it's the cheap insurance I don't have yet, and I'm going to add it. One wrinkle I keep chewing on though: for flashcards specifically, "static for the session" is a little slippery, because the whole reason someone opens the extension is usually that they just generated a fresh batch and want those in Anki now. So a stale cache could quietly hand them last session's cards, which is its own kind of silent-wrong — the exact failure mode this whole series is me trying to stamp out. So I think the honest version is: cache as a fallback the extension only reaches for when the live parse fails, and label it in the output as "served from cache, may be behind" so it's never pretending to be fresh. Insurance, not the default path.
That pairs with the telemetry nicely, actually — telemetry tells me the format broke, the cache keeps existing users afloat for the window, and the label keeps me honest about which one they're looking at.
Appreciate the generous read, genuinely. "Building on platforms you don't own" is turning out to be the whole spine of this series, mostly because the platform keeps handing me new material.