okayurisotto.net

私が好きでやったことが他の人のためにもなったらお得かも!

閲覧中のWebページにある動画のスクリーンショットを撮影するブックマークレットを作った

公開日:
最終更新日:

はじめに

閲覧中のWebページにある動画のスクリーンショットを撮影(ダウンロード)するブックマークレットを作りました。

下に示すURIをブックマークして使ってください。

javascript:void((()%3D%3E%7B%22use%20strict%22%3B(()%3D%3E%7B(async()%3D%3E%7Blet%20d%3Dt%3D%3E%7Blet%20e%3Dt.getFullYear().toString().padStart(4%2C%220%22)%2Co%3D(t.getMonth()%2B1).toString().padStart(2%2C%220%22)%2Ca%3Dt.getDate().toString().padStart(2%2C%220%22)%2Cg%3Dt.getHours().toString().padStart(2%2C%220%22)%2Ch%3Dt.getMinutes().toString().padStart(2%2C%220%22)%2Cu%3Dt.getSeconds().toString().padStart(2%2C%220%22)%3Breturn%60%24%7Be%7D-%24%7Bo%7D-%24%7Ba%7D_%24%7Bg%7D-%24%7Bh%7D-%24%7Bu%7D%60%7D%2Cr%3D%5B...document.querySelectorAll(%22video%22)%5D.filter(t%3D%3E%7Blet%20e%3Dt.getBoundingClientRect()%3Breturn!(e.width%3D%3D%3D0%7C%7Ce.height%3D%3D%3D0%7C%7Ce.top%3Ewindow.innerHeight%26%26e.bottom%3Ewindow.innerHeight%7C%7Ce.left%3Ewindow.innerWidth%26%26e.right%3Ewindow.innerWidth)%7D).sort((t%2Ce)%3D%3E%7Blet%20o%3Dt.width*t.height%2Ca%3De.width*e.height%3Breturn%20o%3Ea%3F-1%3Ao%3Ca%3F1%3A0%7D).at(0)%3Bif(!r)return%3Blet%20n%3Ddocument.createElement(%22canvas%22)%3Bn.width%3Dr.videoWidth%2Cn.height%3Dr.videoHeight%3Blet%20s%3Dn.getContext(%222d%22)%3Bif(!s)return%3Bs.drawImage(r%2C0%2C0%2Cn.width%2Cn.height)%3Blet%20i%3Dawait%20new%20Promise(t%3D%3En.toBlob(t))%3Bif(!i)return%3Bnavigator.clipboard%3F.write%26%26navigator.clipboard.write(%5Bnew%20ClipboardItem(%7B%5Bi.type%5D%3Ai%7D)%5D)%3Blet%20l%3DURL.createObjectURL(i)%2Cc%3Ddocument.createElement(%22a%22)%3Bc.download%3Dd(new%20Date)%2B%22_%22%2Blocation.host.replaceAll(%22.%22%2C%22-%22)%2Cc.href%3Dl%2Cc.click()%7D)()%3B%7D)()%3B%0A%7D)())%3B

これだけでは流石に記事として味気ないので、少し技術的な解説をします。

そもそもブックマークレットとは?

URI(統一資源識別子)には、URL(統一資源位置指定子)の他にもいくつか種類がありますが、その中に、非公式ながらも広く使われているものとして、JavaScript URIがあります。javascript:から始まるもので、次のような形でよく登場します。

<a href="javascript:void(0)" onclick="register">登録ボタン</a>

aタグをonclick属性を使って無理矢理ボタンとして使うときにhrefとして使うもののことです。これは、押下されたとき、javascript:に続けて書かれたJavaScriptをそのページで実行するURIで、それ以上でもそれ以下でもありません。この例ではvoid(0)が実行されますので、何も起きません。

では次のようなURIだった場合はどうでしょうか。(この例ではわかりやすさのためにURIに適切なエスケープをしていません。)

javascript:void((async () => { alert("Hello, world!") })());

これを実行すると、「Hello, world!」という文字列でWebブラウザのダイアログが表示されます。

そして面白いのが、このURIをブックマークすると、そのブックマークを開いたときにURIに書かれたJavaScriptが実行されるということです。つまり任意のタイミングで、あらかじめ登録しておいたJavaScriptを閲覧中のWebページで実行することができるということです。ある種の拡張機能ですね。

このブックマークレットでやっていること

大したことはしていません。

  1. Webページから動画を再生している要素(<video>)を取得
  2. Webページにcanvas要素(<canvas>)を作成
  3. 作成したcanvasに、動画要素を入力値として画像を描く(drawImage()
  4. 描いた画像のデータをObjectURLにする(URL.createObjectURL()
  5. そのURLからダウンロードリンク(<a>)を作成
  6. そのダウンロードリンクを自動クリック

また、対応ブラウザでは画像のクリップボードへのコピーもしています(Clipboard API)。

(async () => {
  const formatDateTime = (date: Date) => {
    const YYYY = date.getFullYear().toString().padStart(4, "0");
    const MM = (date.getMonth() + 1).toString().padStart(2, "0");
    const DD = date.getDate().toString().padStart(2, "0");
    const HH = date.getHours().toString().padStart(2, "0");
    const mm = date.getMinutes().toString().padStart(2, "0");
    const ss = date.getSeconds().toString().padStart(2, "0");

    return `${YYYY}-${MM}-${DD}_${HH}-${mm}-${ss}`;
  };

  const $media = [...document.querySelectorAll("video")]
    .filter(($video) => {
      const rect = $video.getBoundingClientRect();
      if (rect.width === 0 || rect.height === 0) {
        return false;
      }
      if (rect.top > window.innerHeight && rect.bottom > window.innerHeight) {
        return false;
      }
      if (rect.left > window.innerWidth && rect.right > window.innerWidth) {
        return false;
      }
      return true;
    })
    .sort((a, b) => {
      const aSize = a.width * a.height;
      const bSize = b.width * b.height;

      if (aSize > bSize) return -1;
      if (aSize < bSize) return +1;
      return 0;
    })
    .at(0);

  if (!$media) return;

  const $canvas = document.createElement("canvas");
  $canvas.width = $media.videoWidth;
  $canvas.height = $media.videoHeight;
  const canvasContext = $canvas.getContext("2d");

  if (!canvasContext) return;

  canvasContext.drawImage($media, 0, 0, $canvas.width, $canvas.height);
  const blob = await new Promise<Parameters<BlobCallback>[0]>((r) => {
    return $canvas.toBlob(r);
  });

  if (!blob) return;

  if (navigator.clipboard?.write) {
    navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
  }

  const url = URL.createObjectURL(blob);

  const $link = document.createElement("a");
  $link.download =
    formatDateTime(new Date()) + "_" + location.host.replaceAll(".", "-");
  $link.href = url;
  $link.click();
})();

おわりに

SNSとの連携とかもできたら面白そうだと思いましたが、流石にそこまでやるならブックマークレットではなく拡張機能として実装した方がよさそうな気がしたので、やめておきました。また、このときダウンロードされる画像はPNG形式で、適当にJPEGにするだけで容量が半分になったりします。このあたりの圧縮などもクライアントサイドでやれたらよかったのですが、やはりブックマークレットでやるべきことではないですし、そのあたりの知見も足りていないので今回は見送りました。(Squooshがディスコンしていなければ……。)

このブックマークレットはあくまで技術デモということで、どうかよろしくお願いします。