ホエールテック株式会社 ホエールテック株式会社

  • ブログ

Chromeの拡張機能を作る、その時考えていること

巨大な案件の荒波に飲み込まれ必死に船を漕いでいたら、前回のブログの投稿から一年半も経っていました。光陰矢の如し。

はじめに

本記事では勤怠管理用のスプレッドシートに出勤打刻をするショートカットをChromeの拡張機能で作ります。
ただし、作り方そのものはあまり解説しません。偉大なる先人たちのブログ記事やみんなのマブダチ生成AIに聞けばよろし。

本記事ではどのようにトライ&エラーを進めたかの思考的手順・検索の仕方について紹介します。

ざっくり全体をイメージする

勤怠打刻、忘れがち。
なんかこう、出勤したら勝手に勤怠打刻されるようにしたい。
ボタンをひとつ押すだけで打刻するショートカットは以前に作ったのだが、結局そのボタンひとつ押すのすら忘れることが多々あった。

これを解決したい。
この要件を考えたときに真っ先に思いついたのはMacのAutomatorだった。


とりあえず「mac automator 起動時」で検索。
どうやらautomatorからシェルスクリプトを呼ぶアプリを作ってmacのシステム設定からログイン時に開くアプリケーションに追加してあげれば良さそうだ。

次にシャットダウン時に実行するにはどうすれば良いか「mac automator シャットダウン時」で検索。
automatorからmacをシャットダウンする方法はヒットするがmacのシャットダウン時にautomatorを起動する手法は見つからない。
次にgoogleの言語設定を英語にして「mac automator shutdown」とか「mac automator trigger」などで検索。見つからない。
ChatGPTに聞いてみると「残念ながら macOS には Windows の「シャットダウンスクリプト」のような仕組みがなく、標準機能では直接シャットダウン時に自動実行する方法は用意されていません。」とのこと。

ここまで手を動かしながらMacをシャットダウンするときに何かアプリが起動する動作に見覚えがあるか記憶を漁る。
すでに起動しているアプリが終了する際に「本当に終了して良いですか?」みたいな確認が出るのはよく目にするが、シャットダウンする時に「起動」するアプリというのに心当たりはない。
何よりPCをシャットダウンするのにアプリを起動するというのは何となく筋が通らない気がする。

実際軽く検索してみると「mac OSのシャットダウン時にアプリを起動する方法」というのは見つからなかった。ということはアプリをずっと起動しておいて、アプリの起動時に出勤打刻、アプリの終了時に退勤打刻、という挙動になるのだろう。

というわけでautomatorをずっと起動しておく方法を検索。結論から言うとそれをやりたいならautomatorではなくMac OSのネイティブアプリを作る必要がありそうだった。
うーむ、やりたいことに対して思った以上に学習コストがかかりそう。

こんなことを考えている間にも脳内では「PCをシャットダウンする時にシャットダウンを妨げて確認ダイアログを表示するアプリ」について記憶を漁っていたようで、Chromeがヒット。

で、Chromeでアプリ終了時に確認ダイアログを出す方法について調べたところ、これも実現不可能そうということがわかった。

そしたら退勤時刻になったら退勤打刻を促すダイアログを出せばいいか、と思い、それがChrome拡張でできそうか検索。アラームとか類似のアプリがたくさん見つかる。できそうだ。

ここまでで機能要件・学習コストからChromeの拡張機能でアプリを作ることを前提に全体像をイメージ。

① chromeの起動時に出勤打刻をするGoogleAppsScript(GAS)へリクエストする
② 退勤時刻になったら退勤打刻するか確認ダイアログをだし、打刻しないなら10分ごととかにダイアログを表示する
③ ダイアログ内の「打刻する」ボタンが押下されたら退勤打刻をするGASへリクエストする

この流れであれば実装可能そうだし、学習コストも低く済みそうということで次のステップへ。

拡張機能を作ってみる

まずは「chrome 拡張機能 作り方」で検索。でてきたブログ記事をいくつかざっと読んで
・manifest.jsonを作る
・サービスロジックを書いたJavaScriptを作る
・GoogleAppsScriptを作る
・作ったアプリをChromeの拡張機能管理画面左上の「パッケージ化されていない拡張機能を読み込む」からChromeに適用する

という流れで実装できそうだということを把握。
次にChromeの公式ドキュメントからHello World 拡張機能のページを読みながらアプリの実態となるファイルを作成していく。

とりあえずmanifest.jsonをコピペ。jsで機能を書きたいので”content_scripts”の部分をコピペ。
“matches”の項目をどうしようかと考えたのだが、そもそも今回の目的はどこかのURLで実行するのではなく、「chromeアプリの起動」に紐づくものである。
ChatGPTに相談すると「サービスワーカーでやれ」とのこと。なんかさっきのチュートリアルページで見た気がするので「Service Worker でイベントを処理する」の項目を読む。

manifest.jsonの書き方は何となくわかったのでChatGPTに「Chromeの起動時にトーストアラートを表示したいのでやり方を教えてください」とリクエスト。

manifest.json

{
  "manifest_version": 3,
  "name": "Toast on Startup",
  "version": "1.0",
  "permissions": ["notifications"],
  "background": {
    "service_worker": "background.js"
  }
}

background.js

chrome.runtime.onStartup.addListener(() => {
  chrome.notifications.create({
    type: "basic",
    iconUrl: "icon.png", // 48x48 のアイコン画像を用意
    title: "Chrome 起動",
    message: "Chrome が起動しました!"
  });
});

とりあえず生成されたものをコピペして拡張機能に読み込んでみたが何も起きない。エラーすら起きない。
ざっくり読んで、「chromeの起動時」がイベントのトリガーなのだから動くわけがない。
「拡張機能を読み込んだ時」にもトーストを表示したいとChatGPTにリクエスト。

background.js

function showToast() {
  chrome.notifications.create({
    type: "basic",
    iconUrl: "icon.png",
    title: "通知",
    message: "Chrome 起動 or 拡張読み込み時の通知です"
  });
}

// ブラウザ起動時
chrome.runtime.onStartup.addListener(() => {
  showToast();
});

// 拡張機能を読み込んだ直後
chrome.runtime.onInstalled.addListener(() => {
  showToast();
});

拡張機能に読み込んでみたら以下のエラーが発生
「Uncaught (in promise) Error: Unable to download all specified images.」
imagesと言っているのだから画像だろう。生成されたコードを読めば「icon.png」とある。
適当な画像を用意して名前をicon.pngにする。

ついでにディレクトリ構造も変更

├── images
│   └── icon.png
├── js
│   └── background.js
└── manifest.json

manifest.jsonとbackground.js内のパスも変更。

実はここで結構スタックした。
パスの指定が上手くいかず、公式のドキュメントを読んだがいまいち分からず、StackOverflowに上がっているコードを読んだり、chrome拡張機能のサンプルを解説しているブログなどを読んで色んなパスのパターンを試してみて、動くものに辿り着いた。
パスの書き方は数パターンしかないと思ったからこその強行策だが、上手く生成AIに聞ければ一瞬で解決したのだろう。

manifest.json

"service_worker": "js/background.js"

background.js

iconUrl: "/images/icon.png",

読み込んでみたところ上手くトーストアラートが表示された。

完成

この辺りからはあまり考えることはなくChatGPTに聞いて、読んで、理解できたら実装。
最終的なコードが以下である。

拡張機能アプリ側
manifest.json

{
    "name": "Sample",
    "version": "1.0.0",
    "manifest_version": 3,
    "permissions": [
        "notifications"
    ],
    "host_permissions": [
        "https://script.google.com/*"
    ],
    "background": {
        "service_worker": "js/background.js",
        "type": "module"
    }
}

background.js

function showNotification() {
    // 日本時刻で9時前後か判定
    chrome.notifications.create({
        type: "basic",
        iconUrl: "/images/icon.png",
        title: "おはようございます☀️",
        message: "出勤打刻しますか?",
        buttons: [{ title: "はい" }],
        priority: 2
    });
}

chrome.notifications.onButtonClicked.addListener((notifId, btnIdx) => {
    if (btnIdx === 0) {
        fetch("【GASのURL】", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ time: new Date().toISOString() })
        })
        .then(res => {
            console.log(res.status);        // HTTPステータス
            console.log(res.statusText);    // ステータスメッセージ
            return res.text();              // レスポンス本文
        })
        .then(text => console.log(text))
        .catch(err => console.error("Fetch error:", err));
            }
        });

// 起動時
chrome.runtime.onStartup.addListener(() => {
    showNotification();
});

// インストール・拡張リロード時(テスト用)
chrome.runtime.onInstalled.addListener(() => {
    showNotification();
});

GAS

function doPost(e) {
  try {
    // JSONをパース
    const data = JSON.parse(e.postData.contents);
    
    // スプレッドシートIDを指定
    const ss = SpreadsheetApp.openById("【対象のスプレッドシートのID】"); 
    const sheet = ss.getSheetByName("【書き込みたいシートの名称】");
    
    const dt = new Date(data.time); // Chrome拡張から送られるISO文字列
    
    // 日付と時刻に分ける
    const date = Utilities.formatDate(dt, "Asia/Tokyo", "yyyy-MM-dd");
    const time = Utilities.formatDate(dt, "Asia/Tokyo", "HH:mm:ss");
    
    // 新しい行に追加
    sheet.appendRow([date, time]);
    
    return ContentService.createTextOutput(JSON.stringify({status: "success"})).setMimeType(ContentService.MimeType.JSON);
  } catch(err) {
    return ContentService.createTextOutput(JSON.stringify({status: "error", message: err.message})).setMimeType(ContentService.MimeType.JSON);
  }
}

Chromeを立ち上げると以下の通知が表示される。肉球のアイコンが愛らしい。

アラートにホバーするとオプションが表示され、「はい」を押下すると現在時刻を書き込むGASへリクエストする。
当初の要件的には確認もなくリクエストを送ってしまって良いのだが、退勤の際には本当に退勤打刻するかユーザーのアクションが必要なのでこのような実装を試している。

補足

今回書き込みたいスプレッドシートは公開されているものではなかったため、アクセス可能なGoogleアカウントでの認証が必要だった。
GASの実行権限を「自分」にしてGASのアクセス権限を「全員」にしてまずはテストしたが、これではGASのURLを知っている全員が認証なく対象となるスプレッドシートに書き込めてしまう。
そのため、GASのアクセス権限も「自分」に変更。
background.jsのfetchにGoogleアカウントの認証Cookieをつけれないかと試行錯誤してみたが、デベロッパーツールのネットワークタブでリクエストヘッダーを見てみたところ、特に何もしなくてもGoogleアカウントの認証情報込みで送信されているようだった。

とりあえず、GASの実行権限のないアカウントにも作成した拡張機能を読み込んでみたところ、認証で弾かれることを確認。

この辺りの認証情報の扱いに関わる公式ドキュメントを軽く探したが見つからず、OAuth2で認証する方法が正攻法のようである。
とはいえ、本記事も長くなってきたのでOAuth2での認証は割愛する。

さらに言えば退勤打刻の機能も未実装だが、これも割愛。
大丈夫、調べた感じそんな難しくなく実装できる。知らんけど。

おわりに

個人的にはとりあえず動くアプリをがちゃがちゃ好きなように改造するのが勉強法として性に合っている。
Chromeの拡張機能を作ってみるというのは実行環境を意識する必要がなく、自由度も高いし、普段触る機会も多いので、こんな機能があったらいいなという想像も比較的しやすいのではないだろうか。

自分のようなIT未経験エンジニアがやってみるテーマとしていいんじゃないかと思います。

「興味を持って」「1行ずつ」「知識」を増やす癖があれば、AIの生成結果から「知らないこと」を見つけ出す目が養われる気がする。
AIの生成結果の中身を概念すら知らないまま実装するというのは【AI’に’使われている】段階なので、それを保守しようと思ってもまたAIに聞くしかない。誰かに実装内容を説明しようと思っても…また然り。

なので「興味を持って」「1つずつ」「知らないこと」を調べていれば、【AI’を’使う】側であり続けられるのかな、と思います。



つなまよ
つなまよ
叙情系エンジニア
2匹の犬を飼っている駆け出しエンジニア。
伊坂幸太郎、円城塔、六冬和生、志磨遼平が好き。
採用情報
お問い合わせ