見出し画像

資源ごみの出し忘れをChrome拡張で解決してみる by 三瀬

こんにちは、三瀬と申します。
今回は個人用に実装してみた、便利(?)機能を紹介させていただきます。


1. はじめに


皆様におかれましては、毎月欠かすことなく資源ごみを出せていますでしょうか。

自分はそれなりの頻度で段ボールや缶のごみを発生させてしまうためなるべく毎月出すように心がけているのですが、週1,2回の可燃ごみならともかく月1回のタスクとなるとなかなか定着せずにいます。


対策としてSlackbot先生に毎月リマインドを送っていただくようにしているのですが、それでもなぜか当日に忘れてしまうことが多々あり、岡山に引っ越してきてからの戦績は6戦3敗となっています。

ものぐさレベルが高くなってくると一方的な通知では少し効果が薄く、可能でしたら『絶対にごみを出さなければいけない』といったような強制力を持たせられると嬉しいですよね。

ということで、自分は毎日朝食を食べながらぼーっとPCを眺める習慣があるため『回収日当日にWebサイトを開こうとしたらごみを出すまで無限にリマインドを送ってくるChrome拡張』を作ってみました。


2. 作ったもの


名前を忘れてしまったのですが、学生時代に愛用していた『Twitter等を開こうとすると「Just Do It!」という激励を浴びせてくれるChrome拡張』から着想を得て作らせていただきました。

上記画像のdescriptionに記載してある通り、設定した第n回目のm曜日に資源ごみのリマインドをしつこく実施してくる拡張機能になります。


回収日当日に何かしらのサイトを開こうとすると、以下のように専用ページに飛ばされ「資源ごみは出しましたか?」と表示されます。

「無視して続行」を押せばそのまま閲覧が可能になりますが、また別ページに飛ぼうとすると無限にリマインドが繰り返されます。

真面目にごみを出した後「資源ごみを出した」とクリックすれば以降は表示されなくなります。

また、回収日の前日になると「準備はOKですか?」といったページに飛ばしてくるようになります。同様に無視して続行することもできますが、1時間経つと再度飛ばされるようになります。


3. 構成・コード解説


構成

  • manifest.json:拡張機能の設定を記載した基本ファイル

  • background:裏側で常時起動する処理。今回は単純にページの読み込みを検知します

  • options:拡張機能の設定値の登録・更新を行うページ

  • reminder:実際に飛ばすリマインダーページ

.
├── background
│   └── background.js
├── icon.png
├── manifest.json
├── options
│   ├── options.css
│   ├── options.html
│   └── options.js
└── reminder
    ├── pre_reminder.html
    ├── pre_reminder.js
    ├── reminder.css
    ├── reminder.html
    └── reminder.js

今回はかなり単純な作りのため生のjavascript + html + cssで実装したのですが、ReactやVue等を使ったがっつりとしたコンポーネント開発も可能です。他に解決してみたいものがあれば次はReactで何かしら作ってみたいですね。

各コード

要点だけ簡単に解説します。cssは省略します。

  • manifest.json

{
    "manifest_version": 3,
    "name": "資源ごみ回収リマインダー",
    "version": "1.0",
    "description": "月1回、設定した曜日に資源ごみ回収のリマインドをしつこく実施します",
    "permissions": [
        "storage",
        "webNavigation",
        "tabs"
    ],
    "background": {
        "service_worker": "./background/background.js"
    },
    "options_page": "./options/options.html",
    "icons": {
        "128": "icon.png"
    }
}

permissionsで、この拡張機能がアクセスできる権限を設定できます。今回は、ローカルストレージで設定値を管理するのでstorage・ページ読み込みやエラー等の各種イベントを検知できるwebNavigation・タブ操作を行うためのtabs をそれぞれ許可していきます。

また、Chromeアプリの裏側で常時作動するbackground、設定ページ用のoptions_pageをそれぞれ設定しています。


  • options/options.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>資源ごみリマインダーの設定値たち</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Zen+Maru+Gothic&display=swap" rel="stylesheet">
    <link rel="stylesheet" href="options.css" type="text/css">
</head>
<body>
    <h2>設定値たち</h2>
    <p>鬼リマインドをかけるのは第何回目の何曜日か&時間範囲を選択してください。</p>
    <div class="form-container">
        <label>週:</label>
        <select id="weekNumber">
            <option value="1">第1</option>
            <option value="2">第2</option>
            <option value="3">第3</option>
            <option value="4">第4</option>
            <option value="5">第5</option>
        </select>
    </div>
    <div class="form-container">
        <label>曜日:</label>
        <select id="dayOfWeek">
            <option value="0">日曜日</option>
            <option value="1">月曜日</option>
            <option value="2">火曜日</option>
            <option value="3">水曜日</option>
            <option value="4">木曜日</option>
            <option value="5">金曜日</option>
            <option value="6">土曜日</option>
        </select>
    </div>
    <div class="form-container">
        <label>開始時間:</label>
        <input type="number" id="startHour" min="0" max="23">
        <span>:</span>
        <input type="number" id="startMinute" min="0" max="59">
    </div>
    <div class="form-container">
        <label>終了時間:</label>
        <input type="number" id="endHour" min="0" max="23">
        <span>:</span>
        <input type="number" id="endMinute" min="0" max="59">
    </div>
    <button id="save">保存</button>
    <div id="status" class="status"></div>
    <script src="options.js"></script>
</body>
</html>


  • options/options.js

/* 読み込み時に既存設定をフォーム上に反映させるためのイベント */
document.addEventListener('DOMContentLoaded', async () => {
    // 既存の設定を取得
    const settings = await chrome.storage.local.get({
        weekNumber: 4,
        dayOfWeek: 3,
        startHour: 5,
        startMinute: 0,
        endHour: 8,
        endMinute: 30
    });
    
    // フォームに既存の設定を反映させる
    document.getElementById('weekNumber').value = settings.weekNumber;
    document.getElementById('dayOfWeek').value = settings.dayOfWeek;
    document.getElementById('startHour').value = settings.startHour;
    document.getElementById('startMinute').value = settings.startMinute;
    document.getElementById('endHour').value = settings.endHour;
    document.getElementById('endMinute').value = settings.endMinute;
});

/* 保存ボタンのクリック時に設定を保存するイベント */
document.getElementById('save').addEventListener('click', async () => {
    
    // ※本当は要バリデーションチェックです
    const settings = {
        weekNumber: parseInt(document.getElementById('weekNumber').value),
        dayOfWeek: parseInt(document.getElementById('dayOfWeek').value),
        startHour: parseInt(document.getElementById('startHour').value),
        startMinute: parseInt(document.getElementById('startMinute').value),
        endHour: parseInt(document.getElementById('endHour').value),
        endMinute: parseInt(document.getElementById('endMinute').value)
    };
    
    // 保存する
    await chrome.storage.local.set(settings);
    
    // 保存完了メッセージを表示
    const status = document.getElementById('status');
    status.textContent = '設定を保存しました';
    setTimeout(() => {
        status.textContent = '';
    }, 5000);
});

『第何回目の何曜日の 何時何分~何時何分にリマインドを設定するか』 といった期間指定をそれぞれ『第何周目』『曜日』『開始時間』『終了時間』の設定値として保存できるようにしています。

js側 3行目のchrome.storage.local.getでローカルストレージから現在の設定値を取得、35行目のchrome.storage.local.setでローカルストレージに選択した設定値を保存することができます。

現在住んでいる自治体は資源ごみの回収日が第4水曜日となり、「8:30までに出してね」とだけ記載されており開始時間は分からなかったので一旦5:00~8:30 と置いています。

今回は用途を資源ごみのリマインドに限定してみたのですが、少し拡張機能の文言を変えれば『月1で発生するタスクの効果的なリマインド機能』として汎用的に利用することができるようになっています。


  • background.js

/* 回収日当日の時間範囲内かどうかを判定する関数 */
const isScheduledTime = async () => {
    // 現在時刻を取得
    const today = new Date();
    const dayOfWeek = today.getDay();
    const date = today.getDate();
    const hours = today.getHours();
    const minutes = today.getMinutes();
    
    // 設定を取得
    const settings = await chrome.storage.local.get({
        // デフォルト値
        weekNumber: 4, // 第4週
        dayOfWeek: 3, // 水曜日
        startHour: 5, // 開始時間 5:00
        startMinute: 0,
        endHour: 8, // 終了時間 8:30
        endMinute: 30
    });
    
    // 曜日チェック
    if (dayOfWeek !== settings.dayOfWeek) return false;
    
    // 第n週目かチェック
    const firstWeekDayOfMonth = (new Date(today.getFullYear(), today.getMonth(), 1)).getDay(); // 今月の1日が何曜日か
    const firstTargetDay = 1 + ((settings.dayOfWeek + 7 - firstWeekDayOfMonth) % 7); // 最初のm曜日
    const targetDate = firstTargetDay + (settings.weekNumber - 1) * 7; // 第n回目のm曜日
    if (date !== targetDate) return false;
    
    // 時間範囲のチェック
    const currentMinutes = hours * 60 + minutes;
    const startMinutes = settings.startHour * 60 + settings.startMinute;
    const endMinutes = settings.endHour * 60 + settings.endMinute;
    return currentMinutes >= startMinutes && currentMinutes <= endMinutes;
};

/* 回収日前日かどうかを判定する関数 */
const isDayBeforeScheduledTime = async () => {
    const tomorrow = new Date();
    tomorrow.setDate(tomorrow.getDate() + 1);
    
    const settings = await chrome.storage.local.get({
        // デフォルト値
        weekNumber: 4,
        dayOfWeek: 3
    });
    
    // 明日が指定された曜日かチェック
    if (tomorrow.getDay() !== settings.dayOfWeek) return false;
    
    // 明日が第n週目かチェック
    const firstDayOfMonth = new Date(tomorrow.getFullYear(), tomorrow.getMonth(), 1);
    const firstTargetDay = 1 + ((settings.dayOfWeek + 7 - firstDayOfMonth.getDay()) % 7);
    const targetDate = firstTargetDay + (settings.weekNumber - 1) * 7;
    
    return tomorrow.getDate() == targetDate
};
  
/* ページ読み込み時の処理 */
chrome.webNavigation.onBeforeNavigate.addListener(async (details) => {
    // メインフレームの遷移時のみ処理する
    // details.frameIdが1以上の場合、サブフレームの読み込みとなっているためその場合はスキップ
    if (details.frameId !== 0) return;
    
    // 前日&当日の完了フラグ・スキップ時間をストレージから取得
    const storage = await chrome.storage.local.get([
        'completedDate',
        'skipUntil',
        'preCompletedDate',
        'preSkipUntil'
    ]);
    const today = new Date().toISOString().split('T')[0];

    // 前日のチェック
    if (await isDayBeforeScheduledTime()) {
        // ごみを出す準備が完了している場合はスキップ
        if (storage.preCompletedDate === today) return;
        // スキップ有効期限内の場合はスキップ
        if (storage.preSkipUntil && new Date(storage.preSkipUntil) > new Date()) return;

        // リマインダーページ(前日用)に遷移する
        chrome.tabs.update(details.tabId, { 
            url: `./reminder/pre_reminder.html?originalUrl=${encodeURIComponent(details.url)}` 
        });
    }
    
    // 当日のチェック
    if (await isScheduledTime()) {
        // 当月分のごみ出しが完了している場合はスキップ
        if (storage.completedDate === today) return;
        // スキップ有効期限内の場合はスキップ
        if (storage.skipUntil && new Date(storage.skipUntil) > new Date()) return;

        // リマインダーページ(当日用)に遷移する
        chrome.tabs.update(details.tabId, { 
            url: `./reminder/reminder.html?originalUrl=${encodeURIComponent(details.url)}` 
        });
    }
});

isScheduledTimeisDayBeforeScheduledTimeはそれぞれ回収日の当日・前日かを判定する関数です。ローカルストレージから、optionsページで設定した値を取得していい感じに判定しています。(前日リマインドは特に時間範囲は定めていません)

60行目以降の、chrome.webNavigation.onBeforeNavigate.addListenerでページ読み込みを検知して処理を実行しています。引数のdetailsは読み込まれたフレームやタブ、URL等の情報を受け取ることができます。(公式ドキュメント)

読み込み時の処理は、

まずローカルストレージからそれぞれ当日・前日の『リマインドが終了した日付・リマインドをスキップした時間』を取得
→ 前日or当日 かつリマインドを送る条件を満たしている場合、元のURLを保持した状態で今のタブをリマインドページに変更する

といった流れになっています。


  • reminder/reminder.html

鬼のように表示させるページです。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>資源ごみ回収リマインダー</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Zen+Maru+Gothic&display=swap" rel="stylesheet">
    <link rel="stylesheet" href="reminder.css" type="text/css">
</head>
<body>
    <h1>本日は資源ごみ回収日です</h1>
    <p>資源ごみは出しましたか?</p>
    <button id="completed" class="button">資源ごみを出した</button>
    <button id="skip" class="button">無視して続行</button>
    <script src="reminder.js"></script>
</body>
</html>


  • reminder/reminder.js

/* 「資源ごみを出した」クリック時のイベント */
document.getElementById('completed').addEventListener('click', async () => {
    // 完了フラグ(完了日)をローカルストレージに保存
    const today = new Date().toISOString().split('T')[0];
    await chrome.storage.local.set({ completedDate: today });
    
    // 元のURLに遷移
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
    const originalUrl = new URLSearchParams(window.location.search).get('originalUrl');
    chrome.tabs.update(tab.id, { url: originalUrl });
});
  
/* 「無視して続行」クリック時のイベント */
document.getElementById('skip').addEventListener('click', async () => {
    // 1秒間だけリマインドが出ないようにする
    const skipUntil = new Date(Date.now() + 1000).toISOString();
    await chrome.storage.local.set({ skipUntil: skipUntil });

    // 元のURLに遷移
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
    const originalUrl = new URLSearchParams(window.location.search).get('originalUrl');
    chrome.tabs.update(tab.id, { url: originalUrl });
});

「資源ごみを出した」をクリックした場合:当月は完了扱いとなるため完了日をローカルストレージに保存します(background.js側で、完了日が当月の場合リマインドを出さないようになっています)。保存後、元々開こうとしていたページにtabs機能で遷移します。

「無視して続行」をクリックした場合:どうしてもサイトを見たい場合は仕方がないので、今の時間から1000[ms]が経過するまではリマインドが出ないようにしてあげましょう。background.js側で、再度ページを読み込んだ時間がskipUntilの値より大きくなっていたら再度リマインドが出るようになっています。

pre_reminder.html&pre_reminder.jsは、これらと同様の実装になっています。前日リマインドの場合は、16行目のskipUntilを1000[ms]*60*60 に変更すれば1時間後まで出ないように制御できます。


chrome://extensions → 「デベロッパーモード」をオン → 「パッケージ化されていない拡張機能を読み込む」をクリックしてこれらのファイル一式を選択すれば、自分用に実装した拡張機能をインポートすることができます。

これで絶対にごみ出しを忘れたままネットサーフィンで時間を浪費といった悲しみが起きずに済みますね。

ただし自分がちゃんと朝起きて優雅に朝食を摂る前提で作ったので、もし当日に朝寝坊してしまった時は、まぁ….

4. まとめ


今回は

  • 自分が抱えている課題(月1のごみ出しタスクをどうしても忘れてしまうこと)

  • 前提条件(ものぐさ故に一方向のリマインドでは効果が薄い・自宅ではPCを毎朝触る etc.)

を明確にした上で最も負担のない課題解決のアプローチを考えてみました。

年末でもありますので、この機能を頼りにしつつ綺麗な明るい部屋で新年をお迎えしたいですね。

~ITエンジニア募集中~


ハイテックシステムズでは一緒に働いていただける方を募集しています!
詳細は下記をご覧ください。