見出し画像

null²ⁿ(ヌルヌルネクサス)予約カレンダーに「〇△×」と枠数を表示させた話

日付をクリックして、時間帯を開いて、「満席です」。戻って、隣の日をクリックして、また「満席です」。

このサイトに限らず、予約サイトでこの往復を経験したことのある人は多いはずだ。空き状況のデータは届いているはず、でも画面には反映されていない——この「情報はあるのに見えない」問題を解決するために、Tampermonkey用ユーザースクリプト「null2.nexus 空き枠表示」を制作・公開した。

本稿では、その機能と設計、そして公開にあたって検証した安全性について紹介する。


課題:空き数が見えないカレンダー

対象サイトの予約フローは、カレンダーで日付を選び、時間帯を選んで初めて時間別の空きの有無が分かる構造になっている。空いている日を探すには日付を一つずつ開いて回るしかない。色別のサインも表示されているが、「で、わたしたち4人で予約できる枠はどこ?」に対する解は得がたい。

ユーザーが本当に必要としているのは「私が予約できる枠を瞬時に判断できること」である。それを実装した。

解決:3つの表示をページに追加する

本スクリプトは、既存の予約ページに次の3つの表示を描き加える。

1. カレンダーの各日付に、ステータス記号と残り枠数

日付セルの下に「〇 残32/50」のような表示が加わる。緑・黄・赤の3色は残数に応じて自動で切り替わり、カレンダーを一瞥するだけで狙い目の日が判別できる。

2. 時間帯ボタンに、同形式の表示

日付選択後の時間帯画面でも、各ボタンに残り枠数が表示される。

3. 画面右上に、期間全体のサマリーパネル

対象期間の残り枠の合計と、カレンダー・時間帯それぞれの集計を常時表示する。ステータスの判定基準は次の通り。

記号条件〇十分な空きあり△残りが定員の20%以下、または5枠以下×残り0

なお、機能性に振った実装のため既存の美麗なデザイン思想に対して挑戦的になっている点は理解している。落合さんごめんね。

設計:受信データを、そのまま読む

実装の核は「新しく何かを取りに行く」のではなく「すでに届いているものを読む」という方針にある。

予約ページは表示のために空き状況をサーバーから受信している。それを読み取って、残数を計算し、DOM上に表示を描画する。データフローはすべてページ内で完結しており、処理の追加コストは実質ゼロに近い。

UIはダークテーマのサイトに馴染むよう設計した。

導入方法

  1. ブラウザ拡張 Tampermonkey をインストールする

  2. 新規スクリプトを作成し、公開コードを貼り付けて保存する

  3. 対象の予約ページを開く

以上で導入は完了する。ページを開くと自動的に動作し、数秒でカレンダーに表示が反映される。

安全性の検証:このスクリプトが「やらないこと」

ユーザースクリプトの公開にあたっては、動作の透明性がすべての前提になる。本スクリプトの挙動を明確にしておく。

外部への通信は一切行わない。

  • 読み取る対象は、ページ自身がサーバーから受信したデータのみ。閲覧情報や予約内容が作者を含む第三者へ送信されることはない

  • 空き状況の取得はユーザー自身のブラウザ・セッション内で行われる、通常の閲覧と同等のリクエストに限られる

  • 予約・購入操作の自動化機能は持たない。 ボタンの自動クリックや枠の自動確保は一切行わず、機能は表示の追加のみに限定している

  • サーバーへ高頻度アクセスを繰り返すような、負荷を生む挙動はない

  • コードは全文公開しており、上記の挙動は誰でもソースから検証できる

位置づけとしては、ブラウザに届いている情報を読みやすく整形する「閲覧補助ツール」である。通常の手順で予約したいユーザーの無駄なクリックを減らすことだけを目的としている。

なお、利用は各サイトの利用規約の範囲内で、自己責任のもとでお願いしたい。安全性が心配な諸兄はAIにコードすべてを張り付けレビューを依頼されたい。

対応環境について

動作確認はTampermonkey(Chrome/Edge)で行っている。コード自体はマネージャー固有のAPIを使わない標準的な実装のため、Violentmonkey、Greasemonkey、Safari/iOSの「Userscripts」アプリなど、他のユーザースクリプトマネージャーでも同様に動作すると見込んでいる。ただしこれらの環境では未検証のため、導入の際はその点をご了承いただきたい。

コード

※7/10 11:30 1.1.0 微修正


// ==UserScript==
// @name         null2.nexus 空き枠表示
// @namespace    http://tampermonkey.net/
// @version      1.1.0
// @description  予約ページのカレンダーと時間帯ボタンに残り枠数(〇△×)を表示します
// @author       You
// @match        https://null2.nexus/*
// @match        https://ticket.null2.nexus/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // ==========================================
    // 定数:カラートークンとステータス判定
    // ==========================================
    const THEME = {
        OK:   { text: '〇', color: '#34c759', glow: 'rgba(52, 199, 89, 0.5)',  bg: 'rgba(52, 199, 89, 0.15)' },
        FEW:  { text: '△', color: '#ffcc00', glow: 'rgba(255, 204, 0, 0.5)',   bg: 'rgba(255, 204, 0, 0.15)' },
        FULL: { text: '×',  color: '#ff3b30', glow: 'rgba(255, 59, 48, 0.5)',  bg: 'rgba(255, 59, 48, 0.15)' },
    };

    // 残数と定員から〇△×を判定する
    function getStatus(remaining, capacity) {
        if (capacity === 0 || remaining <= 0) return THEME.FULL;
        const ratio = remaining / capacity;
        if (ratio <= 0.2 || remaining <= 5) return THEME.FEW; // 残少判定の閾値
        return THEME.OK;
    }

    // 要素へ複数のCSSプロパティを一括適用する小さなユーティリティ
    function applyStyles(el, styles) {
        Object.assign(el.style, styles);
        return el;
    }

    // ==========================================
    // 0. APIレスポンス同期ヘルパー
    //    ページが自ら呼び出しているfetch/XHRの応答を読み取り、
    //    在庫APIのレスポンスをカスタムイベントとしてページ内に通知する
    //    (新規リクエストの発行や外部送信は一切行わない)
    // ==========================================
    function setupApiResponseObserver() {
        const script = document.createElement('script');
        script.textContent = `
            (function () {
                const originalFetch = window.fetch;
                window.fetch = async function (...args) {
                    const url = typeof args[0] === 'string' ? args[0] : (args[0]?.url || '');
                    const response = await originalFetch.apply(this, args);
                    if (url.includes('/api/v1/products/stocks')) {
                        response.clone().json().then(data => {
                            window.dispatchEvent(new CustomEvent('TM_API_DATA', { detail: data }));
                        }).catch(() => {});
                    }
                    return response;
                };

                const originalOpen = XMLHttpRequest.prototype.open;
                const originalSend = XMLHttpRequest.prototype.send;
                XMLHttpRequest.prototype.open = function (method, url, ...rest) {
                    this._observedUrl = url;
                    return originalOpen.apply(this, [method, url, ...rest]);
                };
                XMLHttpRequest.prototype.send = function (...rest) {
                    this.addEventListener('load', function () {
                        if (typeof this._observedUrl === 'string' && this._observedUrl.includes('/api/v1/products/stocks')) {
                            try {
                                const data = JSON.parse(this.responseText);
                                window.dispatchEvent(new CustomEvent('TM_API_DATA', { detail: data }));
                            } catch (e) {}
                        }
                    });
                    return originalSend.apply(this, rest);
                };
            })();
        `;
        document.documentElement.appendChild(script);
    }

    // ==========================================
    // 1. コントロールパネル(右上・グラスモーフィズム)
    // ==========================================
    function createControlPanel() {
        if (document.getElementById('tm-control-panel')) return;

        const ui = document.createElement('div');
        ui.id = 'tm-control-panel';
        applyStyles(ui, {
            position: 'fixed',
            top: '20px',
            right: '20px',
            padding: '18px',
            backgroundColor: 'rgba(15, 15, 20, 0.75)',
            backdropFilter: 'blur(12px)',
            WebkitBackdropFilter: 'blur(12px)',
            border: '1px solid rgba(255, 255, 255, 0.15)',
            borderRadius: '12px',
            zIndex: '999999',
            boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
            fontFamily: '"Inter", "Segoe UI", sans-serif',
            fontSize: '14px',
            color: '#e0e0e0',
            minWidth: '240px',
        });
        ui.innerHTML = `
            <div style="font-weight:900; margin-bottom:12px; border-bottom:1px solid rgba(255,255,255,0.1); padding-bottom:8px; color:#fff; letter-spacing:0.5px;">
                <span style="font-size:16px;">⏱️</span> 空き枠サマリー
            </div>
            <div id="availability-status" style="line-height:1.4;">同期中...</div>
            <div style="font-size:12px; margin-top:12px; border-top:1px dashed rgba(255,255,255,0.1); padding-top:8px; line-height:1.9;">
                <div id="panel-slot-summary">時間帯: <span style="color:#aaa;">同期待ち...</span></div>
                <div id="panel-calendar-summary">カレンダー: <span style="color:#aaa;">同期待ち...</span></div>
            </div>
        `;
        document.body.appendChild(ui);
    }

    // パネルの各ステータス行を「残n / 全m」形式で更新する
    function updatePanelLine(elementId, label, remaining, capacity) {
        const el = document.getElementById(elementId);
        if (!el) return;
        const status = getStatus(remaining, capacity);
        el.innerHTML =
            `${label}: <span style="color:${status.color}; font-weight:700;">${status.text} 残${remaining}</span>` +
            `<span style="color:#888; font-size:11px;"> / 全${capacity}</span>`;
    }

    // ==========================================
    // 2. 時間帯データの処理
    // ==========================================
    let timeSlotCapacityMap = {};

    // APIレスポンスを再帰的に走査し、capacity/reservedを持つノードを
    // 開始時刻(HH:MM)をキーにしてマップへ格納する
    function extractTimeSlotData(payload) {
        const map = {};

        (function traverse(obj) {
            if (Array.isArray(obj)) {
                obj.forEach(traverse);
                return;
            }
            if (obj === null || typeof obj !== 'object') return;

            if ('capacity' in obj && 'reserved' in obj) {
                const match = typeof obj.start_time === 'string' && obj.start_time.match(/(\d{2}:\d{2})/);
                if (match) map[match[1]] = obj;
            } else {
                Object.values(obj).forEach(traverse);
            }
        })(payload);

        return map;
    }

    // 時間帯マップから残数・定員の合計を算出する
    function summarizeTimeSlots(map) {
        let remaining = 0;
        let capacity = 0;
        Object.values(map).forEach((info) => {
            const rest = typeof info.available !== 'undefined' ? info.available : info.capacity - info.reserved;
            remaining += Math.max(0, rest);
            capacity += info.capacity;
        });
        return { remaining, capacity };
    }

    function handleApiData(event) {
        timeSlotCapacityMap = extractTimeSlotData(event.detail);

        if (Object.keys(timeSlotCapacityMap).length > 0) {
            const { remaining, capacity } = summarizeTimeSlots(timeSlotCapacityMap);
            updatePanelLine('panel-slot-summary', '時間帯', remaining, capacity);
        }
    }

    // ==========================================
    // 3. カレンダー処理
    // ==========================================
    let capacityMap = {};

    async function fetchCalendarData() {
        try {
            const apiUrl = '/api/v1/calendar/colors?from=2026-10-14&to=2026-11-30';
            const response = await fetch(apiUrl);
            if (!response.ok) return;
            const json = await response.json();

            let totalAvailable = 0;
            let totalCapacity = 0;

            json.calendar.forEach((item) => {
                capacityMap[item.date] = item;
                if (item.status === 'available' || item.total_capacity > 0) {
                    totalCapacity += item.total_capacity;
                    totalAvailable += item.total_capacity - item.total_reserved;
                }
            });

            const overall = getStatus(totalAvailable, totalCapacity);
            document.getElementById('availability-status').innerHTML =
                `残り枠: <strong style="color:${overall.color}; font-size:20px; font-weight:900; text-shadow:0 0 8px ${overall.glow};">${totalAvailable}</strong> 枠<br>` +
                `<span style="font-size:12px; color:#aaa; font-family:monospace;">(全 ${totalCapacity} 枠中)</span>`;

            updatePanelLine('panel-calendar-summary', 'カレンダー', totalAvailable, totalCapacity);
        } catch (error) {
            // ネットワーク不通時は無音で継続し、次回の巡回に委ねる
        }
    }

    function buildCapacityInfoNode(info) {
        const remaining = info.total_capacity - info.total_reserved;
        const status = getStatus(remaining, info.total_capacity);

        const infoDiv = document.createElement('div');
        infoDiv.className = 'tm-capacity-info';
        applyStyles(infoDiv, {
            textAlign: 'center',
            marginTop: '4px',
            zIndex: '10',
            position: 'relative',
        });
        infoDiv.innerHTML = `
            <div style="font-size:16px; font-weight:900; color:${status.color}; text-shadow:0 0 8px ${status.glow}; line-height:1; margin-bottom:4px;">
                ${status.text}
            </div>
            <div style="font-size:10px; color:#ddd; font-family:'Menlo', 'Consolas', monospace; letter-spacing:0.5px; background:rgba(0,0,0,0.6); padding:2px 4px; border-radius:4px; border:1px solid rgba(255,255,255,0.1); display:inline-block; box-shadow:0 2px 4px rgba(0,0,0,0.5);">
                残${remaining}<span style="font-size:8.5px; color:#888;">/${info.total_capacity}</span>
            </div>
        `;
        return infoDiv;
    }

    function injectToCalendar() {
        if (Object.keys(capacityMap).length === 0) return;

        const monthEl = document.querySelector('.current-month');
        if (!monthEl) return;

        const match = monthEl.innerText.trim().match(/(\d{4})年(\d{1,2})月/);
        if (!match) return;

        const currentMonthPrefix = `${match[1]}-${match[2].padStart(2, '0')}-`;

        document.querySelectorAll('.date-cell').forEach((cell) => {
            const numberEl = cell.querySelector('.date-number');
            if (!numberEl || !numberEl.innerText.trim()) return;

            const targetDateKey = `${currentMonthPrefix}${numberEl.innerText.trim().padStart(2, '0')}`;
            if (cell.dataset.injectedDate === targetDateKey) return;

            // 月送り等でセルの対象日が変わった場合は、古い描画を除去する
            if (cell.dataset.injectedDate && cell.dataset.injectedDate !== targetDateKey) {
                cell.querySelector('.tm-capacity-info')?.remove();
                cell.dataset.injectedDate = '';
            }

            const info = capacityMap[targetDateKey];
            if (info && info.total_capacity > 0) {
                cell.appendChild(buildCapacityInfoNode(info));
                cell.dataset.injectedDate = targetDateKey;
            }
        });
    }

    // ==========================================
    // 4. 時間帯ボタン処理
    // ==========================================
    function buildSlotInfoNode(info) {
        const remaining = typeof info.available !== 'undefined' ? info.available : info.capacity - info.reserved;
        const status = getStatus(remaining, info.capacity);

        const infoDiv = document.createElement('div');
        infoDiv.className = 'tm-slot-info';
        applyStyles(infoDiv, {
            background: status.bg,
            border: `1px solid ${status.glow}`,
            borderRadius: '6px',
            padding: '4px 8px',
            marginTop: '6px',
            boxShadow: `0 2px 10px ${status.bg}`,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            gap: '6px',
        });
        infoDiv.innerHTML = `
            <span style="font-size:14px; font-weight:900; color:${status.color}; text-shadow:0 0 6px ${status.glow};">${status.text}</span>
            <span style="font-size:12px; color:#fff; font-family:'Menlo', 'Consolas', monospace; font-weight:600; letter-spacing:0.5px;">
                残${remaining}<span style="font-size:10px; color:#999; font-weight:normal;">/${info.capacity}</span>
            </span>
        `;
        return infoDiv;
    }

    function injectToTimeSlots() {
        if (Object.keys(timeSlotCapacityMap).length === 0) return;

        document.querySelectorAll('.time-slot').forEach((slot) => {
            const timeEl = slot.querySelector('.slot-time');
            if (!timeEl) return;

            const timeText = timeEl.innerText.trim();
            if (slot.dataset.injectedTime === timeText) return;

            if (slot.dataset.injectedTime && slot.dataset.injectedTime !== timeText) {
                slot.querySelector('.tm-slot-info')?.remove();
                slot.dataset.injectedTime = '';
            }

            const info = timeSlotCapacityMap[timeText];
            if (info) {
                slot.appendChild(buildSlotInfoNode(info));
                slot.dataset.injectedTime = timeText;
            }
        });
    }

    // ==========================================
    // 5. 初期化とSPAルート変化への追従
    //    トップページからのクライアントサイド遷移
    //    (pushState等)では通常のloadイベントが発火しないため、
    //    URL変化を監視して対象ページに入った時点で初期化する
    // ==========================================
    let hasBootstrapped = false;

    // 現在のURLが対象ページ(購入・注文フロー)かどうかを判定する
    function isTargetPage() {
        return /\/order/.test(location.pathname) || location.hostname === 'ticket.null2.nexus';
    }

    function bootstrapIfNeeded() {
        if (!isTargetPage() || hasBootstrapped) return;
        hasBootstrapped = true;

        setupApiResponseObserver();
        createControlPanel();
        window.addEventListener('TM_API_DATA', handleApiData);

        fetchCalendarData();
        setInterval(() => {
            if (Object.keys(capacityMap).length > 0) injectToCalendar();
            if (Object.keys(timeSlotCapacityMap).length > 0) injectToTimeSlots();
        }, 1000);
    }

    // history.pushState / replaceState はイベントを発火しないため、
    // 呼び出しをラップしてURL変化を検知できるようにする
    (function watchSpaNavigation() {
        const notifyLocationChange = () => window.dispatchEvent(new Event('tm-locationchange'));

        const originalPushState = history.pushState;
        history.pushState = function (...args) {
            const result = originalPushState.apply(this, args);
            notifyLocationChange();
            return result;
        };

        const originalReplaceState = history.replaceState;
        history.replaceState = function (...args) {
            const result = originalReplaceState.apply(this, args);
            notifyLocationChange();
            return result;
        };

        window.addEventListener('popstate', notifyLocationChange);
        window.addEventListener('tm-locationchange', bootstrapIfNeeded);
    })();

    window.addEventListener('load', bootstrapIfNeeded);
    bootstrapIfNeeded(); // すでに対象ページに直接アクセスしていた場合の初回チェック
})();

いいなと思ったら応援しよう!