📋

WebサイトのスクリーンショットとURLを簡単にコピーできるChrome拡張機能を開発している話

2024/12/14に公開

こんにちは、yutaです。
この記事はファインディ株式会社 Advent Calendar 2024の14日の記事になります。

https://adventar.org/calendars/10599

はじめに

私は業務でよくWebサイトのスクリーンショットと、URLを一緒にSlackやGitHubのIssueなどに貼り付けるつけることがよくあります。
例えば、バグの詳細をGitHubに記載するときや、Datadogのアラートの詳細をSlackに投稿しています。特にDatadogの場合はメトリクスが一定期間後に消えるため、後からも振り返りやすいよう画像で残しておくことのメリットも大きいです。

WebサイトのスクショとURLのコピーという単純な2ステップですが、頻繁に行うのと急いでいるときに素早く作業したいと感じる場面も多くなってきました。

そこで、これらの作業を1ステップで実現するChromeの拡張機能を開発しました。

デモ動画
https://youtu.be/1F_a-UpPqgk

拡張機能のアイコンを押して、スクショしたい範囲を選択すると、クリップボードに画像とURLがコピーされています。

また、次のGitHubリポジトリでベータ版ですがコードを公開しています。
https://github.com/yuta-hayashi/shotlink

使用技術について

WXT

Chrome拡張機能はHTML, CSS, JavaScriptといった標準的なWeb技術で開発できます。
しかし、自分でゼロからプロジェクトを作成し、開発、ビルドするのは手間がかかります。

そこで、ブラウザ拡張機能のフレームワークであるWXTを利用しました。
TypeScriptやUIライブラリーを用いたプロジェクトの作成から、開発環境のブラウザの起動、ホットリロードへの対応などが手厚くサポートされており、開発体験がかなり良かったです。

https://wxt.dev/

仕組み

Chrome拡張機能のJavaScriptは、次の3つの箇所で動かすことができ、それぞれ利用できるAPIの制限も異なります。これらのスクリプト間はメッセージでやり取りすることができます。

  • contents script
    • ブラウザで表示されているWebサイトで実行される。DOMにアクセスできる。
  • popup script
    • Chrome拡張機能のアイコンをクリックした時に実行される。popupで表示されている箇所のみで実行される。
  • Service Worker(background)
    • バックグラウンドでブラウザのAPIなどを呼び出すことができる。DOMへのアクセスはできない。

まず、ブラウザの拡張機能のアイコンがクリックされたときに、popupが実行されるので、それを元にcontentsへメッセージを送り、DOMに選択をするためのUIを表示します。
そして、選択範囲が確定すると、タブのスクリーンショットの画像を取得するAPI browser.tabs.captureVisibleTab を実行します。ただし、このAPIはbackgroundで動かす必要があるのでcontentsからbackgroundとメッセージを送りdataURLで受け取っています。
そして、contentsの中で選択範囲でトリミングし、クリップボードにURLと一緒に書き込んでいます。

これらの関係を図示すると次のようになります。

非同期処理とメッセージング

私は今回初めてChrome拡張機能のメッセージングAPIを使いましたが、非同期処理で躓きました。そこでポイントを紹介します。

contentsでは次のようにservice workerへメッセージを送り、そのレスポンスとしてスクリーンショットのdataURLを取得しています。

content.ts
browser.runtime.sendMessage(senderId, { action: 'getScreenShot' }, async (response) => {
  const croppedDataUrl = await cropImage(response.image, area);
  const imageBlob = dataUriToBlob(croppedDataUrl);
})

そして、service workerで次のように、メッセージが来たらスクリーンショットのdataURLを取得しレスポンスを返すコードを書きました。一見するとうまく動いているように見えますが、dataURLはundefinedになってしまいます。

background.ts
export default defineBackground(() => {
  browser.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
    const dataURL = await browser.tabs.captureVisibleTab({ format: 'png' });
    sendResponse({ action: 'getScreenShot', dataURL }); // undefinedが返ってしまう
  });
});

これは、async関数がPromiseをすぐに返してしまい、sendResponseのポートが閉じてしまうことが原因のようです。

そこで、メッセージをListenする関数はasync関数をやめて、thenでレスポンスを返すようにしました。また、メッセージのポートが閉じないようにするため、この関数ではtrueを返すようにします。

background.ts
export default defineBackground(() => {
  browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
    getScreenShot().then((image) => {
      sendResponse({ action: 'getScreenShot', image });
    });
    return true; // ポートを閉じさせないために true を返す
  });
});

これでcontentsへスクリーンショットのdataURLが送信することができます。

このテクニックはこちらの記事が参考になりました。
https://www.mitsuru-takahashi.net/blog/chrome-extension-response/

クリップボードへのコピー

クリップボードへ画像とテキストの2種類のタイプをコピーするには、ClipboardItemにオブジェクトでmimeTypeを指定することで実現できます。
ただし、Slackなどの一部のメッセージアプリケーションしか複数のペーストには対応しておらず、画像だけになってしまうケースもあります。

content.ts
const item = new ClipboardItem({
    'text/plain': new Blob([window.location.toString() || ''], { type: 'text/plain' }),
    'image/png': imageBlob,
  })
await navigator.clipboard.write([item]);

まとめ

このChrome拡張機能開発を通して、自身が感じている小さな不便な作業をより効率化することができました。
そして、Chrome拡張機能特有のAPIやWXTを使った開発体験も新鮮で面白く勉強になりました。

また、現在chromeウェブストアへも申請しており、公開されたら追記します!
最後までお読みいただきありがとうございました。


明日はryu-fさんのフロントエンドエンジニアが未経験のバックエンドに挑戦して得た学びに関する記事です!お楽しみに!

https://adventar.org/calendars/10599

Discussion