Download YouTube subtitles with a few lines of code
Published: 2024-01-06 09:05:11

Paste the following in the javascript developer console while being on a YouTube video page. It will download the subtitles to a .txt file. You can change the language simply on the line on the bottom

```
async function getSubs(langCode = 'en') {
  let ct = JSON.parse((await (await fetch(window.location.href)).text()).split('ytInitialPlayerResponse = ')[1].split(';var')[0]).captions.playerCaptionsTracklistRenderer.captionTracks, findCaptionUrl = x => ct.find(y => y.vssId.indexOf(x) === 0)?.baseUrl, firstChoice = findCaptionUrl("." + langCode), url = firstChoice ? firstChoice + "&fmt=json3" : (findCaptionUrl(".") || findCaptionUrl("a." + langCode) || ct[0].baseUrl) + "&fmt=json3&tlang=" + langCode;
  return (await (await fetch(url)).json()).events.map(x => ({...x, text: x.segs?.map(x => x.utf8)?.join(" ")?.replace(/\n/g,' ')?.replace(/♪|'|"|\.{2,}|\<[\s\S]*?\>|\{[\s\S]*?\}|\[[\s\S]*?\]/g,'')?.trim() || ''}));
}
async function logSubs(langCode) {
  const subs = await getSubs(langCode);
  const text = subs.map(x => x.text).join('\n');
  console.log(text);
  return text;
}
async function downloadSubs(langCode) {
  const text = await logSubs(langCode);
  const blob = new Blob([text], {type: 'text/plain'});
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'subs.txt';
  a.click();
  URL.revokeObjectURL(url);
}

downloadSubs('en');
```


### Retrieving the subtitles (getSubs function)

If you type `await getSubs('de')` in the console (assuming you pasted the code above), you will see an array with all subtitles in german! You can ask for any language, because the YouTube API just translates everything for you, cool he! We don't even have to translate it.  

Let's break down the code into smaller parts to better understand each step:

Retrieve the caption tracks object (ct):

```
let ct = JSON.parse((await (await fetch(window.location.href)).text()).split('ytInitialPlayerResponse = ')[1].split(';var')[0]).captions.playerCaptionsTracklistRenderer.captionTracks;
```

If you are on a YouTube page in the dev console, type `ytInitialPlayerResponse` and big chance you'll find an object with lots of information (including subtitles, click "captions") about this video! 
I decided to retrieve this variable directly from the video page's HTML and extract the ytInitialPlayerResponse from it. Why? Because, if you watch another video, the global variable `ytInitialPlayerResponse` does not get updated automatically. You cannot rely on it, you have to fetch the new video page and extract the object from it. 

Define a helper function (findCaptionUrl):
```
let findCaptionUrl = x => ct.find(y => y.vssId.indexOf(x) === 0)?.baseUrl;
```

This helper function searches in the caption tracks and returns the base URL of the right caption track. 
The `vssId` looks very similar to the languageCode as we will see in the following line.

Build the subtitles URL (url):
```
let firstChoice = findCaptionUrl("." + langCode);
let url = firstChoice ? firstChoice + "&fmt=json3" : (findCaptionUrl(".") || findCaptionUrl("a." + langCode) || ct[0].baseUrl) + "&fmt=json3&tlang=" + langCode;
```

In order of preference we would like to retrieve:
1. a written subtitle in our language (our `firstChoice`)
2. any written subtitle (that we translate to our language)
3. an automatic subtitle in our language
4. any subtitle (that we translate to our language)

Note that the URL also includes the format (json3) and translate language (tlang) query parameters. The Youtube API does this for us :-)

You can skip this detail: If we find an automatic subtitle in our language (3.), we will mute the voice-over in this case automatically, because the video is already in the preferred language. This happens by coincidence, because if you translate (tlang=) to its own language it returns blank subtitles :-)

Fetch and process the subtitles:
```
return (await (await fetch(url)).json()).events.map(x => ({...x, text: x.segs?.map(x => x.utf8)?.join(" ")?.replace(/
/g,' ')?.replace(/♪|'|"|.{2,}|<[sS]*?>|{[sS]*?}|[[sS]*?]/g,'')?.trim() || ''}));
```

This line 
- fetches the subtitles from the previously constructed URL
- parses them as JSON
- processes the events to extract the text of each subtitle. 
- It also cleans the text by removing unnecessary characters and formatting.

Let's break down the code into smaller parts to better understand each step:

Fetch the subtitles and convert them to JSON:
```
await (await fetch(url)).json()
```

This part of the line fetches the subtitles from the provided URL and converts the response into a JSON object.

Process the events:
```
.events.map(x => ...)
```

This part of the line maps over the events array of the JSON object to process each event.

Create a new object for each event:
```
({...x, text: ...})
```

For each event, a new object is created that includes all the original properties of the event (...x) and a new text property that will contain the cleaned subtitle text.

Extract and clean the subtitle text:
```
x.segs?.map(x => x.utf8)?.join(" ")?.replace(/
/g,' ')?.replace(/♪|'|"|.{2,}|<[sS]*?>|{[sS]*?}|[[sS]*?]/g,'')?.trim() || ''
```
This part of the line extracts the subtitle text from the event's segs property and performs the following operations:

1. Maps over the segs array and retrieves the utf8 property of each segment.
2. Joins the segments into a single string with spaces between them.
3. Replaces newline characters with spaces.
4. Removes special characters, quotes, multiple periods, and any content within angle brackets, curly braces, or square brackets.
5. Trims whitespace from the beginning and end of the string.
PS: If any of these operations fail (e.g., because the segs property is undefined), an empty string is returned (|| '').