※本記事はアフィリエイト広告を含みます。
本稿は、東京ディズニーリゾート(TDR)の待ち時間データを、15分間隔かつ秒単位の精度で収集・蓄積するデータパイプラインの構築記録です。
以前、GitHub ActionsとGAS(Google Apps Script)を用いたサーバーレス構成で運用を試みましたが、「プラットフォーム依存による実行遅延(5分~30分の不可避なラグ)」と「リソース共有による実行スキップ」という構造的な欠陥に直面しました。
データの鮮度が価値そのものであるリアルタイム分析において、制御不能な遅延は致命的です。 そこで私は、利便性を優先したGitHub Actionsを撤廃し、Xserver(Cron/Node.js)とGASを組み合わせた「オンプレミス回帰」とも言える堅牢な自律分散システムへと移行しました。
本記事では、以下の技術的課題をどのように解決し、月間数万レコードのデータを安定稼働させているか、その設計思想と実装の全容を公開します。
- 完全制御:Cronによる秒単位の実行スケジューリング
- データ整合性:JST(日本標準時)の厳密な管理と重複排除
- 耐障害性:API制限(429 Error)を考慮した指数バックオフの実装
単なるスクレイピングの手順書としてではなく、「信頼性の高いデータ収集基盤をどう設計すべきか」というエンジニアリングのケーススタディとしてご覧ください。
GitHub Actionsをスクレイピングに使ってはいけない理由
なぜ、無料で手軽なGitHub Actionsを捨てる必要があるのでしょうか。その理由は、以下の2点に集約されます。
- 実行時刻の不透明な遅延
- リソース共有による実行スキップ
GitHub Actionsのスケジュール実行は「ベストエフォート」であり、起動時刻が保証されません。10時00分に設定しても、実際には10時15分に開始されることが頻繁にあり、この15分のズレはデータの価値をゼロにします。(検証した結果、1時間後に実行されることもありました…)
また、混雑時には実行待機やスキップが発生し、ログすら残らず処理が消滅します。原因の切り分けが不可能になることは、長期運用において致命的なリスクとなるため、GitHub Actionsの使用を撤廃したと言うわけです。
XserverとGASによるシステム構成
データの正確性と連続性を高めるため、以下の役割分担でシステムを再構築しました。
- 実行エンジン:Xserver (Node.js)
- データベース:GAS + Google Spread Sheets
Xserver側では、Cronによる秒単位の定期実行、JST(日本標準時)の管理、リトライ処理を担当させます。一方、GAS側にはデータの受信、マスタデータとの照合(名寄せ)、および「蓄積ログ」の保存を担当させます。
この構成により、実行の正確さとデータの扱いやすさを両立させました。
Phase 1:GAS側(受信・蓄積エンジン)の構築
まず、データを受け取って保存する「データベース部分」を構築します。ここでは「蓄積ログ」と、英語名を日本語に変換するための「施設マスタ」の2つを用意します。
1. スプレッドシートの準備
新規スプレッドシートを作成し、以下の2つのシートを用意してください。
① シート名:「蓄積ログ」
収集したデータをひたすら溜めていくメインのシート(蓄積ログシート)です。1行目に以下のヘッダーを設定します。
- A列:日付
- B列:時間
- C列:曜日
- D列:祝日
- E列:パーク
- F列:アトラクション名
- G列:カテゴリ
- H列:エリア
- I列:DPA
- J列:待ち時間
- K列:元時刻
- L列:RunID
② シート名:「施設マスタ」
APIから来る英語名を、日本語名やエリア情報に変換するための辞書シート(マスタシート)です。ヘッダーは以下のように設定します。
- A列:英語名
- B列:日本語
- C列:カテゴリ
- D列:エリア
- E列:DPA
このマスタシートに行を追加していけば、コードを触らずに新アトラクションに対応できます。
2. GASコードの実装 (doPost)
Xserverからのデータを受け取り、マスタシートを参照して日本語化し、ログに追記するコードです。
/**
* TDRデータ受信サーバー (doPost)
* 生データを受け取り、日時分解・マスタ結合を行って蓄積ログへ保存する
*/
function doPost(e) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(10000)) return ContentService.createTextOutput("Busy");
try {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const logSheet = ss.getSheetByName("蓄積ログ");
const masterSheet = ss.getSheetByName("施設マスタ");
// --- 1. マスタデータの読み込み (Map化) ---
// A列:英語, B:日本語, C:カテゴリ, D:エリア, E:DPA
const masterValues = masterSheet.getDataRange().getValues();
const masterMap = new Map();
// ヘッダー行(0)をスキップ
for (let i = 1; i < masterValues.length; i++) {
masterMap.set(masterValues[i][0], {
jp: masterValues[i][1],
category: masterValues[i][2],
area: masterValues[i][3],
dpa: masterValues[i][4]
});
}
// --- 2. 受信データのパース ---
const rawData = JSON.parse(e.postData.contents);
if (!rawData || rawData.length === 0) return ContentService.createTextOutput("No Data");
// --- 3. 重複チェック (RunID) ---
const newRunId = rawData[0].run_id.toString();
const lastRow = logSheet.getLastRow();
if (lastRow > 1) {
// L列(12列目)がRunID
const lastRunId = logSheet.getRange(lastRow, 12).getValue().toString();
if (lastRunId === newRunId) return ContentService.createTextOutput("Skipped");
}
// --- 4. 日時・祝日判定の準備 ---
// Xserverから送られてくる updateTime (例: "2026/02/19 12:00:00") を解析
const updateTimeStr = rawData[0].updateTime;
const dateObj = new Date(updateTimeStr);
// 日付 (YYYY/MM/DD)
const dateStr = Utilities.formatDate(dateObj, "Asia/Tokyo", "yyyy/MM/dd");
// 時間 (HH:mm)
const timeStr = Utilities.formatDate(dateObj, "Asia/Tokyo", "HH:mm");
// 曜日
const weekDay = ["日", "月", "火", "水", "木", "金", "土"][dateObj.getDay()];
// 祝日判定 (Googleカレンダー利用)
const calendarId = 'ja.japanese#holiday@group.v.calendar.google.com';
const holidays = CalendarApp.getCalendarById(calendarId).getEventsForDay(dateObj);
const isHoliday = (holidays.length > 0) ? "祝日" : "平日"; // 土日の場合も"平日"表記になるため、必要なら要調整
// --- 5. データ変換 (マスタ結合 & A-L列生成) ---
const rows = rawData.map(d => {
// マスタ取得(なければ英語名のまま・不明扱い)
const info = masterMap.get(d.name) || {
jp: d.name, category: "-", area: "-", dpa: "-"
};
// 待ち時間の処理(CLOSE等は -1 になっているので文字に直すならここ。今回は数値をそのまま入れる)
const waitVal = (d.status === "OPERATING") ? d.waitTime : -1;
// ※もしログ上で「休止」と文字で出したい場合は上記を書き換える
return [
dateStr, // A: 日付
timeStr, // B: 時間
weekDay, // C: 曜日
isHoliday, // D: 祝日
d.park, // E: パーク
info.jp, // F: アトラクション名
info.category, // G: カテゴリ
info.area, // H: エリア
info.dpa, // I: DPA
waitVal, // J: 待ち時間
d.updateTime, // K: 元時刻
d.run_id // L: RunID
];
});
// --- 6. 蓄積ログへの追記 ---
logSheet.getRange(lastRow + 1, 1, rows.length, 12).setValues(rows);
return ContentService.createTextOutput("Success");
} catch (err) {
return ContentService.createTextOutput("Error: " + err.toString());
} finally {
lock.releaseLock();
}
}
3. デプロイとURLの発行
Xserverからのアクセスを許可するため、以下の設定でデプロイを行います。
画面右上の [デプロイ] > [新しいデプロイ] をクリック。
- 種類の選択:ウェブアプリ
- 次のユーザーとして実行:「自分 (Me)」
- アクセスできるユーザー:全員 (Anyone)※最重要
発行されたURLは、次のフェーズで使用します。(これが後の工程で使用する「gasUrl」に設定するURLとなります。)
Phase 2:Xserver側(実行環境)の構築
WordPress等が稼働している公開領域を汚染しないよう、隔離されたディレクトリに実行環境を作ります。
まずは接続する設定をXサーバーの管理画面で行います。
準備
まずは準備が必要です。
- [サーバーパネル] > [SSH設定] > [ONにする]。
- [公開鍵認証用鍵ペア生成] > パスフレーズを入力し [生成する] をクリック。
ローカル(PC)での配置:
.ssh ディレクトリへ移動し、鍵の権限を厳格に設定(chmod 600)。
SSH接続コマンド:
ssh -i ~/.ssh/〇〇.key 〇〇@〇〇.xsrv.jp -p 10022
※〇〇サーバー番号、ポート番号はデフォルトの22ではなく、Xserver指定の 10022 を使用します。
1. SSH接続とNode.jsの導入
セキュリティを担保するため、公開鍵認証にてSSH接続を行います。接続後、バージョン管理ツール nodebrew を使用して Node.js(v18系推奨)をインストールしてください。
2. プロジェクトディレクトリの作成
ホームディレクトリ直下に隠しディレクトリを作成し、プロジェクトを初期化します。
mkdir -p ~/.tdr_system/fetcher
cd ~/.tdr_system/fetcher
npm init -y
npm install node-fetch@2
Phase 3:収集ロジックの実装 (index.js)
タイムゾーン(JST)を厳密に管理し、API制限対策のリトライ機能を搭載したエンジンを実装します。このフェーズでの重要なポイントは以下の3点です。
- JST時刻の厳密管理
- 指数バックオフ(リトライ)
- 直接ファイルロギング
サーバーの時刻設定に依存せず、Intl.DateTimeFormat で日本時間を強制取得します。また、通信エラー時に即時終了せず、間隔を空けて再試行することで欠測を防ぎます。ログは標準出力ではなく、ファイルへ直接書き込むことでバッファ消失を防ぎます。
/**
* TDRデータ収集・送信エンジン
*/
const fetch = require('node-fetch');
const fs = require('fs');
const path = require('path');
// Phase 1で発行したGASのURL
const gasUrl = 'https://script.google.com/macros/s/xxxxxxxxxxxxxxxxx/exec';
const logPath = path.join(__dirname, 'execution.log');
const writeLog = (msg) => {
const now = new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' });
fs.appendFileSync(logPath, `[${now}] ${msg}\n`);
};
(async () => {
const now = new Date();
const jstFormatter = new Intl.DateTimeFormat('ja-JP', {
timeZone: 'Asia/Tokyo',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: 'numeric', minute: '2-digit', second: '2-digit',
hour12: false
});
const parts = jstFormatter.formatToParts(now);
const p = parts.reduce((acc, part) => { acc[part.type] = part.value; return acc; }, {});
const timestamp = `${p.year}/${p.month}/${p.day} ${p.hour}:${p.minute}:${p.second}`;
const hour = parseInt(p.hour);
const min = parseInt(p.minute);
writeLog(`Check: ${timestamp} (H:${hour} M:${min})`);
// 21:01〜07:59は実行停止
if ((hour === 21 && min > 0) || hour > 21 || hour < 8) {
writeLog("Operation out of hours. Process skipped.");
process.exit(0);
}
const fetchWithRetry = async (url, options = {}, retries = 3) => {
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(url, options);
if (res.ok) return res;
writeLog(`HTTP Error: ${res.status} (Attempt ${i + 1})`);
} catch (err) {
writeLog(`Network Error: ${err.message} (Attempt ${i + 1})`);
}
if (i < retries - 1) await new Promise(r => setTimeout(r, 5000 * (i + 1)));
}
throw new Error(`Max retries reached: ${url}`);
};
try {
const destRes = await fetchWithRetry('https://api.themeparks.wiki/v1/destinations');
const destData = await destRes.json();
const resort = destData.destinations.find(d => d.name.toLowerCase().includes('tokyo'));
const childrenRes = await fetchWithRetry(`https://api.themeparks.wiki/v1/entity/${resort.id}/children`);
const childrenData = await childrenRes.json();
const targetParks = childrenData.children.filter(c =>
c.name.toLowerCase().includes('disneyland') || c.name.toLowerCase().includes('disneysea')
);
const runId = now.getTime().toString();
let allAttractions = [];
for (const park of targetParks) {
const liveRes = await fetchWithRetry(`https://api.themeparks.wiki/v1/entity/${park.id}/live`);
const liveData = await liveRes.json();
const parkType = park.name.toLowerCase().includes('disneyland') ? 'TDL' : 'TDS';
const attractions = liveData.liveData.map(ride => ({
park: parkType,
name: ride.name,
waitTime: (ride.queue && ride.queue.STANDBY) ? (ride.queue.STANDBY.waitTime || 0) : -1,
status: ride.status,
updateTime: timestamp,
run_id: runId
}));
allAttractions = allAttractions.concat(attractions);
}
writeLog(`Sending ${allAttractions.length} records to GAS...`);
const response = await fetchWithRetry(gasUrl, {
method: 'POST',
body: JSON.stringify(allAttractions),
headers: { 'Content-Type': 'application/json' }
});
writeLog(`GAS Response: ${await response.text()}`);
} catch (e) {
writeLog(`CRITICAL: ${e.message}\n${e.stack}`);
process.exit(1);
}
})();
Phase 4:Cronによる完全自動化
不条理な遅延を許さない、Xserver内部スケジューラー(Cron)への登録を行います。
0,15,30,45 8-21 * * * cd /home/〇〇/.tdr_system/fetcher && /home/〇〇/.nodebrew/current/bin/node index.js >> /home/〇〇/.tdr_system/fetcher/debug.log 2>&1

※〇〇にはサーバーIDを入れてください。
WordPressへのリアルタイム連携
蓄積されたデータをブログで表示するため、GAS側にデータ取得用のAPI(doGet)を追加し、WordPress側でショートコードを作成します。
1. GASコードの追加 (doGet)
先ほどのGASプロジェクトに以下の関数を追加し、再度デプロイ(バージョンアップ)してください。
function doGet(e) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName("蓄積ログ");
// 最新の77件(アトラクション数分)だけを取得して返す例
const lastRow = sheet.getLastRow();
// ヘッダー行のみの場合は空を返す
if (lastRow <= 1) return ContentService.createTextOutput("[]");
// 過去の膨大なログから直近のデータのみ抽出
// ※ここでは簡易的に末尾100行を取得するロジックとします
const startRow = Math.max(2, lastRow - 100);
const data = sheet.getRange(startRow, 1, lastRow - startRow + 1, 7).getValues();
// 最新の run_id を持つデータのみにフィルタリング
const lastRunId = data[data.length - 1][6]; // 6番目=run_id
const latestData = data.filter(row => row[6] === lastRunId);
const headers = ["park", "name", "waitTime", "status", "area", "updateTime", "run_id"];
const result = latestData.map(row => {
let obj = {};
headers.forEach((h, i) => obj[h] = row[i]);
return obj;
});
return ContentService.createTextOutput(JSON.stringify(result))
.setMimeType(ContentService.MimeType.JSON);
}
2. WordPress (functions.php)
以下のコードを追加することで、ショートコード [tdr_wait_list] が使用可能になります。
add_shortcode('tdr_wait_list', function() {
// GASのウェブアプリURL
$url = 'https://script.google.com/macros/s/xxxxxxxxxxxxxxxxx/exec';
// Transient APIで60秒間キャッシュ
$cache_key = 'tdr_data_log';
$data = get_transient($cache_key);
if (false === $data) {
$response = wp_remote_get($url);
if (is_wp_error($response)) return 'データ取得中...';
$data = json_decode(wp_remote_retrieve_body($response), true);
set_transient($cache_key, $data, 60);
}
if (empty($data)) return '現在データはありません';
$html = '<div class="tdr-wait-list">';
foreach ($data as $row) {
$wait = ($row['status'] === 'OPERATING') ? $row['waitTime'] . '分' : '一時休止';
if ($row['status'] === 'CLOSED') $wait = '終了';
$html .= '<div class="attraction-item">';
$html .= '<span class="name">' . esc_html($row['name']) . '</span>';
$html .= '<span class="wait">' . esc_html($wait) . '</span>';
$html .= '</div>';
}
$html .= '</div>';
return $html;
});
まとめ
本構築により、GitHub Actionsという不透明なリソースを排除し、自らの管理下で「責任の所在」を明確にしたデータ収集基盤が完成し、現在の待ち時間を15分間隔で取得してWordPressで確認できるところまで実装できました。
次は蓄積したデータを基にグラフとして可視化したり、何曜日の何時くらいが空いているのか、何月に人が多いのかなどの情報を他の予測するサイトより、数字に基づいた立証データを出せると思いますので楽しみにお待ちいただければと存じます!
最後までご覧いただきありがとうございました!
