WebPushの許可ダイアログを、GTM経由で、スクロールとサイトの訪問回数を勘案して表示する


WebPush の購読の許可のタイミングについて、ページ読み込み直後に出力するのは、ユーザーにとって迷惑なため、Lighthouse では以下のような警告を出力しています。

確かに個人的に、毎回出てくるのは非常に迷惑に思うので、GTM経由で、サイトアクセスの記録と、スクロール距離を勘案してダイアログ表示をしてみました。
実施したことを記載します。


WebPush 許可のタイミングについて

Webサイトとして以下、3カテゴリがあるとします。

カテゴリ

  • 無料サイト
    Blog 等、コンテンツを誰でもみることができるサイト
  • 会員サイト
    無料会員制のサイト (登録しなくても見れるコンテンツがあるが、登録しないと見れないコンテンツがある)
  • 有料会員サイト
    無料/有料の概念のあるサイト (登録しなくても見れるコンテンツがある。会員情報を登録するとより多くの情報が見れるが、お金を払うともっと見れる)

通知許可のタイミング

許可を求めるトリガーとして以下が思いつきました。

  • コンテンツの読了。
  • 会員登録直後とか何か自発的な作業完了。
  • サイトの訪問回数。
  • 他ユーザーからのアクションの受信のような受動的な作業完了。

個人的に感じる WebPush の活用法

ユーザーがWebブラウザ使ってさえいれば、許可を求めることもできる。(が、1度しか求められない)
OK か NG かはダイアログでの1アクションで完結します。
ですので、購読自体は、会員制サイトでも会員登録していないユーザーに求めるべきかなと考えます。
だた、通知パーミッション自体は一度しか求められないので、事前に何度も聞けるダイアログを挟め、何度も聞くダイアログを挟めた場合も出力タイミングには気を使う必要はあると思います。1

カテゴリと、通知許可のタイミングの個人ベースの定性的なマトリクス

5段階評価です。1 > ない3 > 普通5 > ある とします。

カテゴリコンテンツの読了自発的な作業完了サイトの訪問回数受動的な作業完了
無料サイト3232
会員サイト3434
有料会員サイト3535

コンテンツの読了と、サイト訪問回数については、どれでも発生する量は変わらないかと思います。
自発的な作業完了、受動的な作業完了については、機能数の多さに比例するところがあるのかと思います。
機能が多ければいいのかという問題はありますが、イベントのトリガー数は、有料会員サイトのほうが多い傾向があるという認識です。


作成した実装の技術要素の説明

作成したプログラムで使用している技術要素について説明します。

ServiceWorker と、Browser でのメッセージ通信

window.postMessage - Web API インターフェイス | MDN で Browser と ServiceWorker で、メッセージの送受信をしています。
ServiceWorker, MessageChannel, & postMessage に、BroadCast で ServiceWorker と、Browser でメッセージをやり取りする方法と、MessageChannel を使って、メッセージ送信元の Client と メッセージの送受信を行う方法がありますが、MessageChannel を使う方法で実装しました。

IndexDB で訪問回数を記録する

ServiceWorker で、ページロードのタイミングで、日付をキーにアクセス日付の訪問回数を記録するようにしました。
localForage/localForage: 💾 Offline storage, improved. Wraps IndexedDB, WebSQL, or localStorage using a simple but powerful API. というライブラリが、IndexDB でのデータ記録も行えて、ブラウザ間の互換性もありそうなので、ServiceWroker 内で importScripts('static/js/localforage.min.js'); して使用するようにしました。

WebPush 許可ダイアログの表示条件

以下の条件をAND で満たす場合、許可ダイアログ表示としました。

  • GTMで、スクロールトリガーを仕込む。 スクロール距離が 90%の場合、イベント発火。

  • イベント発火後、3日間、日付をまたいだアクセスがあれば、WebPushダイアログ表示。 2


実装の説明

前提

ServiceWorker の登録処理等は除外しています。通知を求める処理のみ抜粋して記載します。

PageLoad 時に実行する処理

PageLoad 時には以下の Javascriptを実行しています。
ブラウザ側のスクリプトです。

  • PageConfigure.js
    // メッセージ送信用
    function sendMessage2ServiceWorker(message) {
        return new Promise((resolve, reject) => {
            const channel = new MessageChannel();
            channel.port1.onmessage = (e) => {
                if (e.data.error) {
                    reject(e.data.error);
                } else {
                    resolve(e.data);
                }
            };
            // 登録時は、activateしないため、controller は nullになる
            if (navigator.serviceWorker.controller) {
                 navigator.serviceWorker.controller.postMessage(message, [channel.port2]);
            }
        });
    }
    function dispatchEvent(name) {
        var event;
        try {
            event = new CustomEvent(name);
        } catch (e) {
            event = document.createEvent('CustomEvent');
            event.initCustomEvent(name, false, false);
        }
        window.dispatchEvent(event);  
    } 
    export default function configure() {
         if ('serviceWorker' in navigator) {
            /* eslint-disable no-unused-vars */
            /* 通知をユーザーに求める、eventリスナーでcustomevent登録 */
            window.addEventListener('_sendRequestNotification', () => {
                  // dataLayer変数が設定されていない場合、処理を中断する
                  if ( typeof window.blogPostInfo === 'undefined') {
                       return;
                  }
                   if ('Notification' in window) {
                      // 許可を求める
                      Notification.requestPermission().then((permission) => {
                         if (permission === 'denied' || permission === 'default') {
                             // 拒否 // 無視
                             return;
                         } else if (permission === 'granted') {
                                  let args = {
                                       'userAgent': window.navigator.userAgent,
                                       'blogPostId': window.blogPostInfo.blogPostId,
                                       'gaId': window.blogPostInfo.gaId};
                             sendMessage2ServiceWorker({'command': 'requestNotification', 'args': args});
                         } else {
                             /* eslint-disable no-console */
                             console.log('permission is illegal : %s', permission);
                         }
                     });
                  }
             });
             // GTMからキックする処理    
             window.addEventListener('_isRepeater', () => {
                  console.log('_isRepeater fired..');
                  // dataLayer変数が設定されていない場合、処理を中断する
                  if ( typeof window.blogPostInfo === 'undefined') {
                       return;
                  }
                  if ('Notification' in window) {
                      // ServiceWorkerに再訪ユーザーか問い合わせ
                      sendMessage2ServiceWorker({'command': 'isRepeater', 'args': null}).then((data) => {
                           if (data.result) {
                              // 再訪ユーザであれば、eventDispatchする
                              dispatchEvent('_sendRequestNotification');
                           } else {
                              console.log(' _sendRequestNotification event not fired..');
                           }
                      });
                  }
             });
    
            // 登録時は、activateしないため、controller は nullになる
            if (navigator.serviceWorker.controller) {
                   // ページロード時に、アクセス日付をIndexDBに記録する
                   // serviceworkerにpostmessage
                   window.addEventListener('load', () => {
                          // アクセス日付を記録
                        sendMessage2ServiceWorker({'command': 'storeAccessDate', 'args': null});
                  });
            }
         }
    

functionの説明

以下、function の説明です。

  • _sendRequestNotification イベント
    実際にユーザーに、通知パーミッションを求める処理です。
    ユーザーが許可した場合は、ID の払い出しを行います。

  • _isRepeater イベント
    GTMから発火させるイベントです。
    Notification API が使えるブラウザで且つ、再訪ユーザーであれば、_sendRequestNotification イベントを実行します。

  • load イベント
    ServiceWorker にメッセージを送信して、アクセス日付を記録します。

  • sendMessage2ServiceWorker
    ServiceWorker に メッセージを送信する function です。PostMessage を使って ServiceWorkerにメッセージを送信します。

ServiceWorker 側の処理

以下は、ServiceWorker側の処理の抜粋です。

  • serviceWorker.js
    importScripts('static/js/localforage.min.js');
    
    // 中略........
    
    // Messaging.. Browser側からServiceWorkerへメッセージを送信する
    self.addEventListener('message', (e) => {
        let command = e.data.command;
        let args = e.data.args;
        switch (command) {
            case 'requestNotification':
                // 通知承認要求
                requestNotification(args.userAgent, args.blogPostId, args.gaId);
                break;
            case 'storeAccessDate':
                storeAccessDate();
                break;
            case 'isRepeater':
                isRepeater().then((result) => {
                    e.ports[0].postMessage({'result' : result });
                });
                break;
            default:
                return Promise.resolve();
        }
    });
    
    // 中略........
    
    // -------------------------------------------------------------------
    // 日付文字列を作成するユーティリティ
    // ---------------------------------------------------------
    const dateFormat = {
      fmt: {
        hh: function(date) {
     return ('0' + date.getHours()).slice(-2);
    },
        h: function(date) {
     return date.getHours();
    },
        mm: function(date) {
     return ('0' + date.getMinutes()).slice(-2);
    },
        m: function(date) {
     return date.getMinutes();
    },
        ss: function(date) {
     return ('0' + date.getSeconds()).slice(-2);
    },
        dd: function(date) {
     return ('0' + date.getDate()).slice(-2);
    },
        d: function(date) {
     return date.getDate();
    },
        s: function(date) {
     return date.getSeconds();
    },
        yyyy: function(date) {
     return date.getFullYear() + '';
    },
        yy: function(date) {
     return date.getYear() + '';
    },
        t: function(date) {
     return date.getDate()<=3 ? ['st', 'nd', 'rd'][date.getDate()-1]: 'th';
    },
        w: function(date) {
    return ['Sun', '$on', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getDay()];
    },
        MMMM: function(date) {
     return ['January', 'February', '$arch', 'April', '$ay', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][date.getMonth()];
    },
        MMM: function(date) {
    return ['Jan', 'Feb', '$ar', 'Apr', '@ay', 'Jun', 'Jly', 'Aug', 'Spt', 'Oct', 'Nov', 'Dec'][date.getMonth()];
    },
        MM: function(date) {
     return ('0' + (date.getMonth() + 1)).slice(-2);
    },
        M: function(date) {
     return date.getMonth() + 1;
    },
        $: function(date) {
    return 'M';
    },
      },
      format: function dateFormat(date, format) {
        let result = format;
        for (let key in this.fmt) {
            if (this.fmt.hasOwnProperty(key)) {
                result = result.replace(key, this.fmt[key](date));
            }
        }
        return result;
      },
    };
    
    // -----------------------------------------------
    // アクセス日付を記録するIndexedDBの定義
    // ----------------------
    const accessDate = localforage.createInstance({
        driver: localforage.INDEXEDDB, // Force WebSQL; same as using setDriver()
        name: 'swDB',
        version: 1.0,
        size: 4980736, // Size of database, in bytes. WebSQL-only for now.
        storeName: 'accessDate', // Should be alphanumeric, with underscores.
        description: 'some description',
    });
    // -----------------------------------------------
    // アクセスした日付を記録する
    // ----------------------
    const storeAccessDate = function() {
        let date = dateFormat.format(new Date(), 'yyyyMMdd');
        accessDate.getItem(date).then((value) => {
            let count;
            if (typeof value === 'undefined' || value === NaN) {
                count = 1;
            } else {
                count = 1 + value;
            }
            return accessDate.setItem(date, count).then(() => {
                return accessDate.length().then((length) => {
                    if (length > 5) {
                        accessDate.key(0).then((key) => {
                            console.log(key);
                            accessDate.delete(key);
                        }).catch((value) => {
                        console.log('Raise error.');
                        });
                    }
                });
            });
        });
    };
    // -------------------------------------------------
    // Repeaterユーザーか判定して返す。
    // --------------------------------------------
    const isRepeater = function() {
      return accessDate.keys().then((keys) => {
        // 日をまたいで3回以上のアクセスがあるか判断する
        if (keys.length >= 3) {
            return true;
        }
        return false;
      });
    };
    

function の説明

  • self.addEventListener('message'
    ブラウザ側のスクリプトの Postmessage を 受け取る処理です。command 名称を受け取り、それで処理を分岐させています。e.ports[0].postMessage({'result' : result }); でブラウザ側の呼び出し元に、戻り値をpostMessage で送信しています。
    MessageChannel で private な postMesage を送信する場合は、port 指定をしてメッセージを返信できます。

  • storeAccessDate
    日付ごとのアクセス回数を記録するfunction です。5日分を記録し、5日以上になったら、過去のものを削除するようにしました。
    消えることが試せてないのでもしかしたら、期待通りに動かないかもしれません。

  • isRepeater
    再訪ユーザーかを判定する処理です。3日以上のアクセスのあるユーザを再訪ユーザーとしてtrue を返します。

GTM に設定するカスタムHTMLタグ

  • HTMLタグ
    GTMには、以下のカスタムスクリプトを設定しました。_isRepeater カスタムイベントを発火させています。
    try-catchの記述は、IE向けの記述です。

    <script>
    var event;
    try {
      event = new CustomEvent('_isRepeater');
    } catch (e) {
      event = document.createEvent('CustomEvent');
      event.initCustomEvent('_isRepeater', false, false);
    }
    window.dispatchEvent(event);
    </script>
    

  • タグ呼び出しオプション
    1 ページにつき 1度 を設定しました。

GTM のトリガー指定

  • トリガーの種類
    スクロール距離

  • 縦方向スクロール距離
    割合 90% を指定

  • このトリガーの発生場所
    一部のページ
    blog 記事ページでのみ発動するように URL 指定しました。


実装した感想

  • ブラウザ - ServiceWorker のメッセージ通信をしたくて実装したが、もっとシンプルな作りでよい
    PostMessage でのメッセージの連鎖を実装したくてこのような作りになりましたが、実際に使う場合はもっとやり方を考えたほうがいいかと思います。
    メンテナンス時に何しているのかわからなくなりそうです。

  • 作りこむと大規模化する ServiceWorker
    機能てんこ盛りにしていくと、ServiceWorkerが大規模化していきます。
    importScripts を有効活用して役割ごとにファイルを分割していくべきだと思いました。後そもそも、大きくなると、初期ロードの時間は長くなるので、本当に必要な機能かどうかは判断がいるかと思います。

  • Promise を覚える
    Promise の仕組みに慣れる必要があります。デバッグで止まったり、止まらなかったりします。


参考

作成しているもの一式は、mezzanine-theme-clean-blog/clean_blog_frontend at master · kemsakurai/mezzanine-theme-clean-blog
以上です。


  1. 一撃必殺で、ものすごいコンバージョンが高いタイミングで出す。という考えもあるかもしれません。 

  2. home screenへ追加を促すダイアログの表示条件が2日間であるため、それより後に出すべき、という思いで3日にしています。 

コメント