Youtube Phrase Counter

Product log · Chrome extension 2026-09-05
Transcript Cues
0:39 / 0:39 CC

How a nine-word request turned into a browser extension, told the way the extension itself would tell it: one timestamped cue at a time.

Every build starts as a favor shaped like a sentence: “I'd like to create a YouTube plugin that scrapes video transcripts and returns a count of specific phrases.” Nine words, and underneath them, four decisions nobody had made yet: where the data comes from, what the interface even is, how forgiving the matching should be, and how much of the internet an extension is allowed to quietly talk to. What follows is that build's own transcript: what got decided, what broke, and the two moments a five-word requirement from the person who actually has to use the thing changed the roadmap.

Chapter 1

The Pitch

0:00

The word "plugin" could have meant a browser extension, a CLI script, or a plugin for some editor that hadn't even been named. Rather than open with twenty questions, a quick look at the working directory found an existing browser extension already sitting there, built the same way: Manifest V3, a small popup, nothing elaborate. That settled the format without spending a single question on it. This was going to be a browser extension, built the way this person already builds them.

0:03

Everything else genuinely needed a call, so three decisions went to the actual stakeholder instead of getting guessed at:

DecisionOptions on the tableCall
Transcript sourceYouTube's internal API vs. scraping the rendered pageInternal API
InterfacePopup vs. an in-page overlayPopup
Matching rulesCase sensitivity, whole-word, per-match timestampsAll three
Chapter 2

The Build

0:07

The first version of the pipeline was pure plumbing: pull the video ID off the tab URL, fetch the watch page's own HTML, and pick the caption track list back out of it with one regex:

"captionTracks":(\[[^\]]*\])

Then fetch that track's URL for the actual caption text. The manifest asked for almost nothing: activeTab, scripting, and one host permission for youtube.com. No API key, no server, no account.

0:10

The decision that actually mattered was smaller and easier to skip: captions don't respect sentence boundaries, so a two-word phrase can straddle a break between one caption and the next. Instead of searching caption-by-caption, every line got concatenated into a single string with a running index of where each caption started. Search the whole thing once, then binary-search that index to hand each match back its real timestamp. It was a small thing to add, and it's the difference between a count you can trust and one you can't.

Chapter 3

The Icon

0:13

No Pillow, no ImageMagick, no Inkscape installed. What the machine did have was qlmanage, macOS's Quick Look thumbnailer, which happily rasterizes an SVG if you ask it to preview one, chained into sips for the resize pass. One rounded-square SVG in, four PNGs out, and no new dependencies installed:

# 16px
# 32px
# 48px
# 128px
Chapter 4

The Debugging Loop

0:16

First incident, reported plainly:

Error · popup.js
Failed to execute 'json' on 'Response': Unexpected end of JSON input
Cause: forcing &fmt=json3 onto a caption track that didn't reliably support it, so the response body came back empty. Fix: stop asking for JSON and parse the format YouTube always returns, plain XML, with DOMParser instead.
0:19

Second incident, same shape, different cause:

Report · from the user
"youtube returned an amepty transcript response... but the transcript is loaded on the page"
Cause: the fetch was running from the extension's own chrome-extension:// origin, and YouTube's caption endpoint was quietly declining it. No error, just nothing. Fix: run the exact same fetch inside the YouTube tab via chrome.scripting.executeScript, so the request carries the tab's real cookies and referer instead of the extension's.

Both errors came from the same root problem. An undocumented endpoint doesn't explain a failure, it just returns nothing.

Chapter 5

The Pivot

0:22

With the network approach finally working, the obvious next question came in: would it be better to inject straight into the transcript panel and reuse its own search bar? The honest answer at the time was no, not yet. Reading the panel's DOM meant it had to already be open, and it was one redesign away from breaking, while the network call, now fixed, worked whether or not anyone had opened anything.

0:25
“I would open it manually. It should only open manually. I don't want to expose the transcript on every video play.”The actual requirement, one message later.

That line set a real privacy constraint: never expose the transcript without the user opening it themselves. It mattered more than whatever was already built, so the build changed to match it.

0:28

The rebuild read whatever was already rendered in an open transcript panel and never touched it otherwise. No auto-opening, no background fetch, no exposure on videos nobody asked to search. The host permission for youtube.com came out of the manifest entirely; activeTab was already enough. A smaller manifest, and a smaller promise to the person installing it.

Chapter 6

The Ghost in the Panel

0:31
Report · from the user
the transcript panel is open but i get this error:
Transcript panel isn't open.

Looking straight at an open panel, told it wasn't there. That kind of contradiction only has one honest next step: stop guessing and go look.

0:35

A live devtools session on an actual video turned up the answer in about four queries: ytd-transcript-segment-renderer, the element the scraper was looking for, returned zero matches. YouTube had rolled a redesigned transcript panel out from under the project mid-build, replacing it with a new custom element, transcript-segment-view-model. The fix shipped with both selectors, the new one first and the old one as a fallback, verified live in the console against the real page before it ever touched the extension.

YouTube shipped that redesign sometime between when the code went out and when the bug got reported. Scraping someone else's UI means their release notes are quietly part of your dependency graph.

Chapter 7

Roads Not Taken

0:39

One question came back a second time, differently framed: could the counter live inside the transcript panel itself, instead of a popup? It's a genuinely nicer idea: no icon click, with results sitting right next to the text they describe. But it trades today's silent, per-click permission grant for a standing one, and it doubles the surface area already coupled to a markup that had just proven, that week, that it could shift without warning.

“Not yet.” Two words, and the best roadmap decision is sometimes the one you write down and don't build.

Proof · running on a real video
The extension's popup open on a real YouTube video, showing four phrases (right here, right there, y'all, um) each with a match count and a row of clickable timestamps.

163 captions parsed, four phrases, every match timestamped: "right here" turned up 40 times, "y'all" 43, "um" 34.

What actually shipped

  • Reads whatever's already open in YouTube's own transcript panel, and never opens it for you.
  • Case-insensitive and whole-word matching, one phrase per line, no limit on how many.
  • Every match links back to its timestamp and seeks the real video.
  • Runs on activeTab + scripting only, with no standing host permission and no background access.
#product-management #chrome-extension #manifest-v3 #build-log