YouTube動画をChromeから手軽にダウンロードする最適解はブックマークレットだと思う

YouTubeダウンロードでお世話になっていた拡張機能が動かなくなった。代替サービスを探すもパッとするものがなかったので、自分でツールを作ることにした。

## 要件
今、Chrome系ブラウザで見ている動画を、`{動画タイトル名}.mp4` で名前を付けて保存したい。[^filename]

## Chrome拡張機能ではYouTubeページの変数にアクセスできないが、ブックマークレットならできるのでクリティカルな情報を直接取得できる
YouTubeのページをよく調べてみると `ytInitialPlayerResponse` という変数に動画の情報（タイトルなど）や、URLなど、欲しい情報が全て詰まってるから、この変数を読み取れば簡単に機能を実現できるのではと思った。

それではじめChrome拡張機能[^google様]で作ろうとしたが、Chrome拡張はウェブページのDOM要素にはアクセスできても変数や関数といったスクリプト要素にはどうしてもアクセスできない（空間が別）[^ex-var]。先人たちはどうしてたのか見ると、どうも自分のサーバーに送って解析して‥みたいなことをしていたらしい。

それでふと思ったのが、ブックマークレットはそのページで直に実行されるから、ページに定義されている `ytInitialPlayerResponse` にもアクセスできるよな、ってこと。[^dameBookmarklet]


## ブックマークレットを編集するのに便利なサイト
- [Online JavaScript beautifier](https://beautifier.io/)
  - bookmarkletをjavascriptに整形する 
- [bookmarklet maker](http://userjs.up.seesaa.net/js/bookmarklet.html)
  - javascript をブックマークレット化する
- 参考：[Bookmarkletを編集して再びBookmarkletにする \| 普通のサラリーマンのiPhone日記](http://iphone-diary.com/?p=9251)

特にbookmarklet maker はとても重宝している。少し修正してはbookmarklet maker にかけて様子を見るということは結構やる。
以下にあるスクリプトをブックマークレットとして使用する場合もbookmarklet maker にかけよう。

**注意: 最近のchromeではブックマークレットのリンクをブックマークに追加しようとするとURLが `about:blank#blocked` という形に変更されてしまう。**
今のところ、ブックマークURLを直接編集することで対応できるが、そのうちブックマークレット自体が完全に動かなくなる日も来るのかもしれない。セキュリティの問題なのかもね。


## 動画本体URLと、動画のタイトルの取得
配列 `ytInitialPlayerResponse.streamingData.formats` の要素のキー `url` が実際の動画のURLで、たとえばこれは `https://‥.googlevideo.com/videoplayback‥` みたいなのになっている。これをダウンロードしたらOK。配列になってるのは品質の違うファイルが複数用意されているため。ただしこのファイルはタイトル情報を持っていないため、ダウンロードしようとすると `video.mp4` というふうに、タイトルなしでDLされるため、タイトル情報を別に取得して、動画ファイル名として設定しておきたい。
動画のタイトルは `ytInitialPlayerResponse.videoDetails.title` から参照できる。

## a要素href属性に直にURLを指定してダウンロードしようとすると失敗する（クロスオリジンのため）
a要素を生成してそのdownload属性に動画タイトルを設定して、`click()` すればダウンロードが始まるはず‥(<a href="https://qiita.com/kerupani129/items/99fd7a768538fcd33420">JavaScript でファイル保存・開くダイアログを出して読み書きするまとめ - Qiita</a>)
ところが‥

```javascript:ダウンロードがうまくいかない例
javascript: (function(d) {
  var url = ytInitialPlayerResponse.streamingData.formats[0].url;
  var title = ytInitialPlayerResponse.videoDetails.title;
  var a = d.createElement('a');
  a.href = url;
  a.download = title;
  a.click();
})(document);
```

これを実行しても、ダウンロードされず、代わりにダウンロード先として指定した内容がそのまま直に開かれてしまう（ここではmp4のファイルが別タブで直接開かれる）。これはブラウザがクロスオリジンを認めていないため。つまり `www.youtube.com` から `*.googlevideo.com` にあるファイルをダウンロードさせることはできない（YouTube動画ファイルはyoutube.comではなくgooglevideo.comに置かれている。）

- 参考
  - [download属性 \- リンク先ファイルのダウンロード \| HTMLの属性リファレンス](https://lab.syncer.jp/Web/HTML/Reference/Attribute/download/1/)
  - <a href="https://scrapbox.io/nwtgck/%3Ca%3E%E3%81%AEdownload%E5%B1%9E%E6%80%A7%E3%81%AF%E5%90%8C%E4%B8%80%E3%82%AA%E3%83%AA%E3%82%B8%E3%83%B3%E3%81%98%E3%82%83%E3%81%AA%E3%81%84%E3%81%A8%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89%E5%87%BA%E6%9D%A5%E3%81%AA%E3%81%84">&lt;a&gt;のdownload属性は同一オリジンじゃないとダウンロード出来ない - nwtgck / Ryo Ota</a>

## Blob化してDLすることでクロスオリジン制限を回避する
<a href="https://scrapbox.io/nwtgck/%3Ca%3E%E3%81%AEdownload%E5%B1%9E%E6%80%A7%E3%81%AF%E5%90%8C%E4%B8%80%E3%82%AA%E3%83%AA%E3%82%B8%E3%83%B3%E3%81%98%E3%82%83%E3%81%AA%E3%81%84%E3%81%A8%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89%E5%87%BA%E6%9D%A5%E3%81%AA%E3%81%84">&lt;a&gt;のdownload属性は同一オリジンじゃないとダウンロード出来ない - nwtgck / Ryo Ota</a> の代替案1 にある方法[^FileSaver]、<a href="https://developer.mozilla.org/ja/docs/Web/API/XMLHttpRequest">XMLHttpRequest</a> を介してDLすることでクロスオリジン制限を回避する（ただし、ファイルサイズが大きい場合、クリックしてから保存ダイアログが出るまでにラグが生じる）。

- <a href="https://qiita.com/azu369yu/items/8998e1e1536a5acfb7b3">blob &amp; createObjectURL について - Qiita</a>
- window.URL.createObjectURL()は、URLのメソッドです。Blob、Fileを参照するための一時的なURLを作成します。

```javascript:うまくダウンロードできるようになった例
javascript: (function(d) {
  var url = ytInitialPlayerResponse.streamingData.formats[0].url;
  var title = ytInitialPlayerResponse.videoDetails.title;
  var xhr = new XMLHttpRequest();
  xhr.open('GET', url, true);
  xhr.responseType = 'blob';
  xhr.onload = function(){
    var a = d.createElement('a'), blob = xhr.response;
    a.download = title;
    a.rel = 'noopener';
    a.href = URL.createObjectURL(blob);
    setTimeout(function(){ URL.revokeObjectURL(a.href) }, 4E4);
    setTimeout(function(){ a.click() }, 0);
  };
  xhr.onerror = function(){
    console.error('could not download file');
  };
  xhr.send();
})(document);
```

## DLするファイルの品質を選択できるようにする。ついでにファイル名にアップロード日やチャンネル名を含める
今までダウンロードURLを `formats[0].url` の決め打ちにしていたが、DLしたいファイルの品質を選びたいときもあるだろう。`formats`を走査して、品質一覧から選択できるようにする（formatsの最初の要素に 360p、次の要素に 720p の動画情報が含まれていることが多いようだ。[^highquality]）。

さらに、ファイル名にその動画のアップロード日やチャンネル名を含んでいると後から整理するときに便利だ。
ファイル名のフォーマットは `${author}(${uldt})${title}`（チャンネル名、アップロード日、動画タイトル）にしたが、都合に応じて適宜カスタマイズしよう。

```javascript:入力ダイアログでファイルの品質を選択できるようにしてもろもろを改良した例
javascript: (function(d) {
  var title = ytInitialPlayerResponse.videoDetails.title;
  var fms = ytInitialPlayerResponse.streamingData.formats,
    msg = `${title}\nDL対象をindexで指定してください\n`;
  for (let i=0,len=fms.length; i<len; i++){
    msg += `${i}:%20${fms[i].qualityLabel}%20(${fms[i].quality})\n`;
  }
  var targ = window.prompt(msg, 0);
  if (!targ){
    return
  } else if (!targ.match(/^\d+$/) || targ >= fms.length){
    alert(`無効な値です:%20"${targ}"`);
    return
  }
  var author = ytInitialPlayerResponse.videoDetails.author;
  var uldt = ytInitialPlayerResponse.microformat.playerMicroformatRenderer.uploadDate;
  var xhr = new XMLHttpRequest();
  xhr.open('GET', fms[targ].url, true);
  xhr.responseType = 'blob';
  xhr.onload = function(){
    var a = d.createElement('a'), blob = xhr.response;
    a.download = `${author}(${uldt})${title}`.replace(/[./\\:*?"<>|]/g,
      s=>{return String.fromCharCode(s.charCodeAt(0)+0xFEE0)});
    a.rel = 'noopener';
    a.href = URL.createObjectURL(blob);
    setTimeout(function(){ URL.revokeObjectURL(a.href) }, 4E4);
    setTimeout(function(){ a.click() }, 0);
  };
  xhr.onerror = function(){
    console.error('could not download file');
  };
  xhr.send();
})(document);
```
これで、希望するファイル品質を index 番号で入力して選択DLできるようになった。さらにファイル名に含んでいると不都合が出そうな文字 `[./\\:*?"<>|]` は全角文字に置換することで回避できるようにした。[^jspp]

## 注意点
### 別の動画に飛んだ時にはページをリロードする必要がある
変数 `ytInitialPlayerResponse` を使えばDLに必要な情報は取得できるということが分かったが、この変数はYouTubeのページを遷移しても更新されない。つまりAAAAという動画のページを開いたとき、`ytInitialPlayerResponse` には動画AAAAの情報がセットされるが、その後にその動画のリンクからBBBBという動画ページへ遷移しても、`ytInitialPlayerResponse` の中身はAAAAの情報のままである。
`ytInitialPlayerResponse` の情報をBBBBのものに更新するにはページを再読み込みする必要がある。
### 開いてからしばらく放置していたページの動画はDLできない
動画のリンクに有効期限があるらしく、それを過ぎた動画はDLできないためだと思う。


## YouTubeをDLするのに他に有用と思われるツール
- [VGet Online \| Download free videos and audios online](https://www.vget.online/)
- <a href="https://github.com/ytdl-org/youtube-dl">ytdl-org/youtube-dl</a>
- [pytube/pytube](https://github.com/pytube/pytube)

[^filename]: 記事の後の方を見てもらえばわかるが、最終的には、アップロード日、チャンネル名、動画タイトルを含めたファイル名を付けるようにした。

[^google様]: 最近はYouTubeをDLするような拡張はGoogleストアに「ダメだよ」される

[^ex-var]: かつては拡張からでも`location.href` を介してjavascriptを投げることができたらしい。今現在はそんなことはできなくなっている。[Chrome拡張でページのグローバル変数にアクセスしたい｜UTAGE\.WORKS カワシマ ［フリーランスウェブクリエイター］｜note](https://note.com/kkotaro0111/n/nd527d31957a7)

[^dameBookmarklet]: すでに誰かが作ってるだろうと思って「YouTube ダウンロード ブックマークレット」で検索してみたが、出てきたのは動かなくなった古いやつか、よくわからないサイトに飛ばされる粗悪なものだけだった。

[^FileSaver]: <a href="https://github.com/eligrey/FileSaver.js/blob/556db1f6df33da0fe8954dcea3fb49b11830e9c5/src/FileSaver.js#L34-L45">「eligrey/FileSaver.js のここらへん」</a>という言葉を頼りに参考にした。`setTimeout(function(){ URL.revokeObjectURL(a.href) }, 4E4);`というのは、40秒後にこのリンクを無効にするという意味ね。`URL.createObjectURL()`で作成したリンクは不要になったら`revokeObjectURL()`で都度削除してメモリを解放した方がいいらしい。

[^highquality]: それ以上の品質の動画は `ytInitialPlayerResponse.streamingData.adaptiveFormats` の方にあるが、この動画urlには音声が含まれていない。

[^jspp]: 入り組んだJavaScriptの変数を調べるときには <a href="https://lab.syncer.jp/Tool/JavaScript-PrettyPrint/">JavaScriptのコード整形ツール</a> にかけて整形してから調べると欲しい要素がどこにあるか見つけやすくなるよ。