0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

既存Webシステムの新着をChrome拡張で監視してスマホに通知してみた

0
Posted at

はじめに

前回、Webシステムからスマートフォンへ通知する方法について調べました。

その中で、

  • Notifications API
  • Push API
  • Service Worker
  • WebSocket
  • ポーリング

など、通知やリアルタイム処理に関係する仕組みを知りました。

今回はもう一歩進めて、

Webシステム自体を変更せず、ブラウザ側から新着を検知してスマートフォンへ通知できないか?

という方法を試してみました。

そこで作ったのが、Chrome拡張機能による新着監視です。

一定間隔で対象ページを取得し、前回取得した未読IDと比較します。

新しいIDが追加されていた場合だけ、通知サービスへHTTPリクエストを送り、スマートフォンへ通知します。

※この記事では、実際に使用したURL・サービス固有の名称・環境情報などをサンプル用の値に置き換えています。


1. 作ったもの

全体像は次のようになっています。

┌─────────────────┐
│ Chrome拡張機能   │
└────────┬────────┘
         │
         │ chrome.alarms
         │ 一定間隔で実行
         ▼
┌─────────────────┐
│ 対象Webシステム  │
└────────┬────────┘
         │
         │ HTMLを取得
         ▼
┌─────────────────┐
│ 未読IDを抽出     │
└────────┬────────┘
         │
         │ 前回のIDと比較
         ▼
      新着あり?
         │
         ▼
┌─────────────────┐
│ 通知サービス     │
└────────┬────────┘
         │
         ▼
    スマートフォン

サーバー側から新着情報を送ってもらうのではなく、

Chrome拡張側から一定間隔で状態を確認する

方式です。

これはポーリングの考え方です。


2. ファイル構成

今回のChrome拡張は、主に次のような構成にしました。

web-notifier/
├── manifest.json
├── background.js
├── options.html
└── options.js

それぞれの役割は次の通りです。

ファイル 役割
manifest.json Chrome拡張全体の設定
background.js 定期的な新着確認・差分検出・通知
options.html 通知先などを入力する設定画面
options.js 設定値の保存・読み込み

今回の中心となるのは background.js です。


3. manifest.json

まずChromeに、

このフォルダはChrome拡張機能です

と認識してもらうために必要なのが manifest.json です。

公開用に一般化すると、例えば次のような構成です。

{
  "manifest_version": 3,
  "name": "Web Message Notifier",
  "version": "1.0.0",
  "description": "Webシステムの新着情報を検知して通知します",
  "permissions": [
    "storage",
    "alarms"
  ],
  "host_permissions": [
    "https://example.com/*",
    "https://ntfy.sh/*"
  ],
  "background": {
    "service_worker": "background.js"
  },
  "options_page": "options.html"
}

順番に見ていきます。


manifest_version

"manifest_version": 3

今回は Manifest V3 を使用しています。

Chrome拡張機能の現在の仕組みでは、バックグラウンド処理にService Workerを利用できます。


permissions

"permissions": [
  "storage",
  "alarms"
]

今回必要なのは主に2つです。

storage

chrome.storage.local

を使用するための権限です。

今回は、

  • 前回取得した未読ID
  • 通知先の設定値

などを保存するために使用しています。

alarms

chrome.alarms

を使用するための権限です。

一定間隔で新着確認処理を起動するために使っています。


4. なぜsetIntervalではなくchrome.alarmsなのか

setInterval

指定した時間間隔で、同じ処理を繰り返し実行するJavaScriptの関数。

最初は、

setInterval(checkUnread, 60000);

のようにすればいいのでは?

とも考えられます。

しかし、Manifest V3ではバックグラウンド処理としてService Workerを使用します。

"background": {
  "service_worker": "background.js"
}

Service Workerは、ずっと起動し続けることを前提とした仕組みではありません。

イベントが発生したときに起動して処理し、不要になれば停止することがあります。

そのため、

Service Workerを常駐させる
        +
setInterval()

という考え方ではなく、

chrome.alarms
      ↓
時間になったらイベント発生
      ↓
Service Workerが処理

という形にしました。

ここはChrome拡張を作る中で重要だったポイントです。


5. host_permissions

次に、

"host_permissions": [
  "https://example.com/*",
  "https://ntfy.sh/*"
]

を設定します。

今回の拡張機能では、

対象Webシステム

と、

通知サービス

の2か所へHTTPリクエストを送ります。

そのため、拡張機能からアクセスする必要があるホストを指定しています。


6. background.jsをService Workerとして登録する

"background": {
  "service_worker": "background.js"
}

これによって background.js がChrome拡張のバックグラウンド処理を担当します。

今回の background.js では、

定期実行
↓
HTML取得
↓
未読ID抽出
↓
前回との差分比較
↓
通知
↓
状態保存

という処理を行います。


7. chrome.alarmsで定期実行する

まずアラーム名を決めます。

const ALARM_NAME = "check-messages";

拡張機能をインストールしたときに、

chrome.runtime.onInstalled.addListener(() => {
  chrome.alarms.create(ALARM_NAME, {
    periodInMinutes: 1
  });

  checkUnread();
});

としてアラームを作成します。

これで一定間隔でイベントを発生させられます。

さらに、Chrome起動時にもアラームを作成します。

chrome.runtime.onStartup.addListener(() => {
  chrome.alarms.create(ALARM_NAME, {
    periodInMinutes: 1
  });

  checkUnread();
});

そしてアラームが発生したら、

chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === ALARM_NAME) {
    checkUnread();
  }
});

として新着確認を実行します。

全体として、

Chrome
  ↓
alarmイベント
  ↓
background.js
  ↓
checkUnread()

という流れになります。


8. Webページを取得する

新着確認の中心となるのが checkUnread() です。

まず対象ページを取得します。

const UNREAD_URL =
  "https://example.com/messages";

const response = await fetch(UNREAD_URL, {
  method: "GET",
  credentials: "include",
  cache: "no-store"
});

ここでは、

credentials: "include"

を指定しています。

対象ページがログインを必要とする場合、認証状態を利用したリクエストが必要になるためです。

また、

cache: "no-store"

も指定しています。

新着確認なのに古いキャッシュを取得すると困るため、毎回現在の状態を確認する意図があります。


9. HTTPステータスを確認する

ページを取得できなかった場合は、その回の処理を終了します。

if (!response.ok) {
  console.log(
    "ページ取得失敗:",
    response.status
  );

  return;
}

例えば、

200 → 成功

404 → ページが存在しない

500 → サーバー側エラー

など、HTTPステータスによって結果が変わります。

fetch() が完了したからといって、必ず正常なレスポンスとは限らないため、

response.ok

を確認しています。


10. HTMLを取得する

レスポンスからHTMLを文字列として取得します。

const html = await response.text();

今回はAPIからJSONを取得しているわけではありません。

取得しているのは通常のWebページです。

つまり、

GET
 ↓
HTML
 ↓
必要な情報を探す

という処理になります。


11. HTMLから未読IDを探す

今回のWebページでは、各項目へのリンクに一意なIDが含まれていました。

公開用に単純化すると、

messageId=123
messageId=456
messageId=789

のような形式です。

そこで正規表現を使ってIDを抽出します。

const matches = [
  ...html.matchAll(/messageId=(\d+)/g)
];

正規表現の、

\d+

は、

1文字以上の数字

を表します。

例えば、

messageId=123

なら、

123

を取り出せます。


12. Setで重複を取り除く

HTML内に同じIDが複数回登場する可能性もあります。

そこで、

const currentIds = [
  ...new Set(matches.map(match => match[1]))
];

としています。

例えば、

["101", "101", "102", "103", "103"]

という結果でも、

Set を通すと、

["101", "102", "103"]

になります。

これを現在の未読ID一覧として扱います。


13. 前回の状態をchrome.storage.localから取得する

新着かどうかを判断するには、

前回はどのIDが存在していたか

を知る必要があります。

そこで、

const data = await chrome.storage.local.get([
  "lastUnreadIds",
  "notificationTopic"
]);

としてChrome側に保存したデータを読み込みます。

そして、

const previousIds =
  Array.isArray(data.lastUnreadIds)
    ? data.lastUnreadIds
    : null;

として前回のID一覧を取得します。


14. 初回は通知しない

初回起動時には、まだ前回データがありません。

例えば起動した時点で、

["101", "102", "103"]

という未読があったとします。

これを全部新着として通知すると、

拡張機能を入れる前から存在したもの

まで通知されてしまいます。

そこで、

if (previousIds === null) {
  await chrome.storage.local.set({
    lastUnreadIds: currentIds
  });

  return;
}

として、初回だけは通知しません。

初回起動

現在
[101, 102, 103]

      ↓

通知しない

      ↓

基準として保存

という処理です。


15. 件数ではなくIDの差分を見る

今回特に重要なのがここです。

単純に未読件数を比較すると、

前回:3件
今回:3件

なので、

新着なし

と判断してしまいます。

しかし実際には、

前回
101
102
103

から、

今回
101
103
104

になっている可能性があります。

102 が消えて 104 が追加されています。

つまり件数は同じでも、

新しい項目は存在しています。

そこで、

const newIds = currentIds.filter(
  id => !previousIds.includes(id)
);

とします。

これは、

現在存在するIDのうち、前回存在しなかったもの

を探しています。

結果は、

["104"]

となります。

これによって、

未読件数の変化

ではなく、

未読集合の変化

を見ることができます。


16. 新着があれば通知する

新しいIDが存在した場合だけ通知します。

if (
  newIds.length > 0 &&
  data.notificationTopic
) {
  // 通知
}

通知メッセージも件数によって変えます。

const message =
  newIds.length === 1
    ? "新しいメッセージがあります"
    : `新しいメッセージが${newIds.length}件あります`;

これによって、

新しいメッセージがあります

または、

新しいメッセージが3件あります

のような通知になります。


17. 通知サービスへPOSTする

スマートフォンへの通知には、HTTPリクエストを受け取ってPush通知へ変換してくれるサービスを利用しました。

例えば、

const notifyResponse = await fetch(
  `https://ntfy.sh/${encodeURIComponent(
    data.notificationTopic
  )}`,
  {
    method: "POST",
    body: message
  }
);

という形です。

流れとしては、

Chrome拡張
     ↓
HTTP POST
     ↓
通知サービス
     ↓
スマートフォン

となります。

つまりChrome拡張側でスマートフォン用のPush通知システムを一から構築しているわけではありません。

新着検出まではChrome拡張、Push通知部分は通知サービス

と役割を分けています。


18. encodeURIComponent()を使う理由

通知先をURLの一部として使用するため、

encodeURIComponent(
  data.notificationTopic
)

としています。

URLにそのまま埋め込むと問題になる可能性のある文字を、安全にURLへ含められる形式に変換するためです。


19. 通知後に現在の状態を保存する

最後に、

await chrome.storage.local.set({
  lastUnreadIds: currentIds
});

として、今回取得したIDを保存します。

すると、

今回のcurrentIds
       ↓
保存
       ↓
次回のpreviousIds

になります。

これを繰り返すことで、新しく追加されたIDだけを検出できます。


20. 設定画面も作る

通知先の設定値を background.js に直接書くのは避けたかったので、設定画面を用意しました。

manifest.json では、

"options_page": "options.html"

と指定します。

これによってChrome拡張の設定画面として options.html を開けるようになります。

簡単なHTMLは次のような形です。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Notifier 設定</title>
</head>

<body>

  <h2>Notifier 設定</h2>

  <label for="topic">
    通知トピック
  </label>

  <input
    id="topic"
    type="password"
    placeholder="通知先のトピック名"
  >

  <button id="save">
    保存
  </button>

  <p id="status"></p>

  <script src="options.js"></script>

</body>
</html>

入力欄を、

type="password"

にすることで、設定値が画面上にそのまま表示されないようにしています。

設定画面で入力した値は options.js から chrome.storage.local に保存し、background.js が読み込む構成にできます。

options.html
     ↓
ユーザーが設定
     ↓
options.js
     ↓
chrome.storage.local
     ↑
background.js

これによって、

設定画面とバックグラウンド処理を分離

できます。


21. 全体を整理する

今回の処理を最初から並べると、次のようになります。

① Chrome拡張を起動
        ↓
② chrome.alarmsを登録
        ↓
③ 一定間隔でイベント発生
        ↓
④ Service WorkerがcheckUnread()を実行
        ↓
⑤ fetch()で対象ページを取得
        ↓
⑥ HTMLから未読IDを抽出
        ↓
⑦ chrome.storage.localから
   前回のIDを取得
        ↓
⑧ currentIdsとpreviousIdsを比較
        ↓
⑨ 新しいIDを発見
        ↓
⑩ 通知サービスへHTTP POST
        ↓
⑪ スマートフォンに通知
        ↓
⑫ currentIdsを保存
        ↓
⑬ 次回の確認へ

22. Chrome拡張の構成も理解できた

今回作る前は、Chrome拡張というと、

Webページの見た目を変えるJavaScript

くらいのイメージがありました。

しかし実際には、

manifest.json
     ↓
拡張機能全体の設定

Service Worker
     ↓
バックグラウンド処理

chrome.alarms
     ↓
定期実行

chrome.storage
     ↓
状態・設定の保存

options.html
     ↓
設定UI

のように役割を分けて作ることができます。

通常のWebアプリケーションとは少し違った構成になっているのが面白いところでした。


23. 作って理解できたこと

今回特に勉強になったのは、

「通知を実現したい」という要件から、必ずしもPush機能そのものを対象システムへ追加する必要はない

ということです。

今回のように、

定期取得
+
HTML解析
+
状態保存
+
差分検出
+
外部通知

を組み合わせる方法もあります。

また、新着判定についても、

未読が何件あるか

ではなく、

前回と比べて
新しいIDが存在するか

を見ることで、より目的に合った判定ができました。


まとめ

今回作った仕組みは、

Chrome Extension
       ↓
chrome.alarms
       ↓
Service Worker
       ↓
fetch()
       ↓
HTML解析
       ↓
未読ID抽出
       ↓
chrome.storage.local
       ↓
差分検出
       ↓
HTTP POST
       ↓
Push通知

という構成になりました。

今回触れた技術は、

  • Chrome Extensions
  • Manifest V3
  • Service Worker
  • chrome.alarms
  • chrome.storage.local
  • Fetch API
  • HTTP GET / POST
  • HTML解析
  • 正規表現
  • Set
  • filter()
  • ポーリング
  • 状態管理
  • 差分検出

です。

一つひとつはそれほど複雑な処理ではありません。

しかし、

「取得 → 解析 → 保存 → 比較 → 通知」

と役割を分けて組み合わせることで、既存のWebシステムを変更せずに新着通知の仕組みを作ることができました。

単に通知を作っただけでなく、

ブラウザ拡張がどのようにバックグラウンドで処理を実行し、状態を保持し、外部サービスとHTTP通信するのか

を理解する良い題材になりました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?