I run a small side project that lets people download YouTube subtitles. I assumed this part would be easy: grab the caption file, save it, done.

It wasn't. Download YouTube's auto-generated captions and open them in a video player or a text editor. Every line shows up two or three times, and the text is full of tags like <00:00:01.360><c>. People who wanted a transcript got this instead:

so today we're going to talk so today we're going to talk so today we're going to talk about parsing about parsing 

This post covers what is actually inside that file, and the small parser I wrote to turn it into clean SRT, VTT and plain-text transcripts. No dependencies, about 60 lines of TypeScript.

What YouTube actually gives you

Manually uploaded captions are usually fine. The trouble is the auto-generated track, which is what most videos have. It's WebVTT, and a few cues look like this:

WEBVTT Kind: captions Language: en 00:00:01.120 --> 00:00:03.590 align:start position:0% so<00:00:01.360><c> today</c><00:00:01.680><c> we're</c><00:00:02.000><c> going</c><00:00:02.300><c> to</c><00:00:02.500><c> talk</c> 00:00:03.590 --> 00:00:03.600 align:start position:0% so today we're going to talk 00:00:03.600 --> 00:00:06.230 align:start position:0% so today we're going to talk about<00:00:03.840><c> parsing</c> 

Three problems:

  1. Inline word timings. <00:00:01.360><c> today</c> is how YouTube does karaoke-style highlighting. Useful for a player, noise for everyone else.
  2. Rolling duplicates. Each cue repeats the previous line at the top so captions "scroll" up on screen. There are even 10 ms cues (03.590 --> 03.600) that exist only to hold the previous line. Convert this naively to SRT and every sentence appears twice.
  3. Cue settings and HTML entities. align:start position:0% on the timing line, plus &amp;, &nbsp; and friends in the text.

On top of that, SRT and VTT disagree on timestamps. VTT allows MM:SS.mmm and uses a dot; SRT wants HH:MM:SS,mmm with a comma.

The parser

The idea: split the file into blocks, find the timing line in each, clean the text, and drop any line that's identical to the last line we kept.

interface Cue { start: string; // HH:MM:SS.mmm end: string; text: string; } const TIME = /(\d{1,2}:)?\d{2}:\d{2}\.\d{3}/; function normaliseTime(t: string) { // WebVTT allows MM:SS.mmm; SRT needs HH:MM:SS return t.split(":").length === 2 ? `00:${t}` : t.padStart(12, "0"); } export function parseVtt(vtt: string): Cue[] { const blocks = vtt.replace(/\r/g, "").split(/\n{2,}/); const cues: Cue[] = []; let lastLine = ""; for (const block of blocks) { const lines = block.split("\n"); const timingIndex = lines.findIndex((l) => l.includes("-->")); if (timingIndex === -1) continue; // header, NOTE, STYLE blocks const [rawStart, rawEnd] = lines[timingIndex].split("-->").map((s) => s.trim()); // .match() also throws away cue settings like "align:start position:0%" const start = rawStart.match(TIME)?.[0]; const end = rawEnd.match(TIME)?.[0]; if (!start || !end) continue; const textLines = lines .slice(timingIndex + 1) .map((l) => l .replace(/<[^>]+>/g, "") // <00:00:01.360>, <c>, </c>, <i>… .replace(/&amp;/g, "&") .replace(/&lt;/g, "<") .replace(/&gt;/g, ">") .replace(/&nbsp;/g, " ") .trim() ) .filter(Boolean) // auto-captions repeat the previous line at the top of each cue .filter((l) => l !== lastLine); if (textLines.length === 0) continue; // the 10 ms "holder" cues end up here lastLine = textLines[textLines.length - 1]; cues.push({ start: normaliseTime(start), end: normaliseTime(end), text: textLines.join("\n") }); } return cues; } 

Run the sample above through it:

  • Cue 1: tags stripped, leaving so today we're going to talk. Kept.
  • Cue 2: its only line equals lastLine, so the cue is empty. Skipped.
  • Cue 3: the first line is a duplicate and gets dropped; about parsing is kept.

Two cues, no repeats. 🎉

A few details that bit me:

  • Split on blank lines, not on -->. Header blocks (WEBVTT, Kind:, Language:) and NOTE blocks have no timing line. findIndex returning -1 skips them cleanly.
  • Strip \r first. Some caption files have Windows line endings, and \n\n won't match \r\n\r\n.
  • Remember the last line you kept, not the last line you saw. Otherwise the empty holder cues reset the comparison.

Writing it back out

Once you have clean cues, every output format is a one-liner.

export function toSrt(cues: Cue[]) { return cues .map((c, i) => `${i + 1}\n${c.start.replace(".", ",")} --> ${c.end.replace(".", ",")}\n${c.text}\n`) .join("\n"); } export function toVtt(cues: Cue[]) { return `WEBVTT\n\n${cues.map((c) => `${c.start} --> ${c.end}\n${c.text}\n`).join("\n")}`; } export function toTxt(cues: Cue[]) { return cues.map((c) => c.text.replace(/\n/g, " ")).join("\n"); } 

SRT needs a running index and commas in the timestamps. Re-emitting VTT is worth doing too: a cleaned VTT plays far better than YouTube's original in VLC or a <track> element. Plain text is what most people actually want: something they can paste into notes, a translator, or an LLM.

Don't build an open proxy by accident

My first version was a Next.js route handler that took a caption URL, fetched it, converted it and returned the file. It worked, and it would also have fetched any URL anyone passed it. That's a free proxy for whoever finds it.

So the route only fetches from YouTube's own hosts, over HTTPS:

export function isAllowedSubtitleUrl(raw: string) { try { const u = new URL(raw); if (u.protocol !== "https:") return false; return u.hostname === "youtube.com" || u.hostname.endsWith(".youtube.com"); } catch { return false; } } 

Check hostname on a parsed URL. Don't use raw.includes("youtube.com"), which https://evil.example/?youtube.com passes happily.

Trade-offs I accepted

This parser is deliberately simple, and it's not perfect:

  • A speaker who really says the same line twice in a row loses the second one. In practice I haven't seen anyone notice, and the duplicate problem was far worse.
  • Styling is gone. Positioning, italics and colours are stripped. That's fine for transcripts; it's wrong if you need broadcast-grade subtitles.
  • The HTML entity handling is minimal. It covers what YouTube actually emits, not the full entity table.

If you need full spec compliance, use a proper WebVTT parser. If you need readable text out of YouTube captions, this is enough.

Bonus: a searchable transcript in the browser

Since parseVtt has no dependencies, the same function runs on the client. In my app the cleaned VTT is fetched once and parsed in the browser into a transcript you can search, with timestamps you can switch off and a copy button. No second API call per format.

Disclosure: I built this for TubeSave's subtitle downloader, a free tool that does exactly this: paste a YouTube link, pick a language, get SRT, VTT or TXT. The code above is the real code it runs. Happy to answer questions in the comments.