タイムズカー予約を自前カレンダーへ完全同期!n8nで構築する「キャンセル忘れ防止」機能

先日、タイムズカーの予約をキャンセルし忘れ、一度も車に乗っていないのに数千円の利用料金が発生するという苦い経験をしました。原因は単純なヒューマンエラー。予約したこと自体をカレンダーに入れ忘れ、脳から完全に消去されていたのです。

「二度とこの無駄な出費を繰り返さない」

そう決意し、予約状況をスマホのカレンダーへリアルタイムに反映させる仕組みを構築することにしました。この手の自動化はiPhoneの「ショートカット」やオートメーション機能でも可能ですが、私はあえてサーバサイドの n8n で解決することを選びました。

目次

なぜ「n8n」という選択肢だったのか

「スマホで完結させない」ことには、実運用上の確固たる理由があります。

  • デバイスの制約がない: スマホの電源が切れていても、圏外にいても、サーバは24時間体制でメールを監視し、処理を完結させてくれます。
  • 既存インフラ(DAViCal)との親和性: すでに自宅で運用しているカレンダーサーバ(DAViCal)へ、直接データを流し込みたかった。
  • 高度な分岐処理: 単なる「登録」だけではありません。「予約変更」「取消」「返却証」による実利用時間の確定、さらには「車両トラブルによる貸出不能通知」まで、複雑なメール形式を判別してカレンダーを書き換えるには、n8nの柔軟なロジックが必要不可欠でした。

n8nの環境構築(docker-compose.yml)

まずはDockerでn8nを立ち上げます。第一部で解説したOAuth認証時のみ N8N_HOST を localhost に設定し、認証完了後はサーバの固定IPに戻して運用します。

services:
n8n:
image: n8nio/n8n:latest
container_name: n8n
restart: always
ports:
- "5678:5678"
environment:
- N8N_HOST=localhost # 認証後は固定IPに変更
- N8N_PORT=5678
- N8N_PROTOCOL=http
- NODE_ENV=production
- GENERIC_TIMEZONE=Asia/Tokyo
- WEBHOOK_URL=http://localhost:5678/ # 認証後はサーバの固定IPに変更
- N8N_SECURE_COOKIE=false;
volumes:
- ./n8n_data:/home/node/.n8n

n8nの初期セットアップ

コンテナが起動したら、ブラウザで http://<n8nサーバ>:5678 にアクセスします。最初にアカウント作成画面が表示されますが、個人利用の場合は以下の手順で進めます。

  1. アカウント作成: メールアドレスとパスワードを設定。
  2. アンケート画面: 「What best describes your company?(あなたの組織を最もよく表すものは?)」という質問が表示されます。
    • ここでは 「I’m not using n8n for work(仕事では利用しない)」 を選択すればOKです。
      I'm not using n8n for work(仕事では利用しない)
  3. 開始: 「Get started」ボタンを押すと、メイン画面(キャンバス)が表示されます。
  4. 作成開始: 「Create Workflow」をクリックして、自分だけの自動化ラインを描き始めましょう!

n8n overview

ワークフローの設計思想

メールの件名によって「通常用」と「事故トラブル用」のパーススクリプトを分離しています。(予約番号などの書式が異なるため)

  1. Gmail API: 1分おきに新着メールを確認。
  2. Switch Node: タイトルで分岐。
    • 「予約車両が利用できない可能性があります」 → 事故用ルート
    • それ以外(登録・変更・取消・返却) → 通常ルート
  3. JavaScript Node: それぞれの形式に合わせて日時や予約番号を抽出。
  4. HTTP Request: DAViCalへ PUT(作成・更新)または DELETE(削除)を実行。

workflows

JavaScriptによるパース処理

メール本文からカレンダー形式(.ics)を生成する心臓部です。

【通常ルート用】予約・変更・取消・返却を管理


const items = $input.all();

return items.map(item =&gt; {
// Gmailの出力に合わせて text または textPlain を取得
const body = item.json.text || item.json.textPlain || "";
const subject = item.json.subject || "";

// 1. 共通: 予約番号の抽出
const resIdMatch = body.match(/■予約番号\n(\d+)/);
const resId = resIdMatch ? resIdMatch[1] : "000000";

// 2. アクションの判定
let action = "PUT";
if (subject.includes("予約取消完了")) {
action = "DELETE";
}

// 3. データの抽出 (PUTの場合のみ)
let startTime = "", endTime = "", stationName = "タイムズカー", stationUrl = "", carModel = "";

if (action === "PUT") {
// 車両の抽出
const carMatch = body.match(/■車両\n([^\n]+)/);
carModel = carMatch ? carMatch[1].trim() : "";

// ステーション名とURLの分離
const stationMatch = body.match(/■ステーション\n([^\n]+)\n(https?:\/\/[^\n]+)/);
if (stationMatch) {
stationName = stationMatch[1].trim();
stationUrl = stationMatch[2].trim();
}

// 【修正ポイント1】日時パース用ヘルパー (Tを挿入するように変更)
const formatIcsDate = (str) =&gt; {
if (!str) return "";
const clean = str.replace(/[\/ :]/g, ''); // 202603141000
return clean.slice(0, 8) + 'T' + clean.slice(8, 12) + '00'; // 20260314T100000
};

if (subject.includes("返却証")) {
const timeMatch = body.match(/■利用時間\n(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}) - (\d{4}\/\d{2}\/\d{2} \d{2}:\d{2})/);
if (timeMatch) {
startTime = formatIcsDate(timeMatch[1]);
endTime = formatIcsDate(timeMatch[2]);
}
} else {
const startMatch = body.match(/■利用開始日時\n(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2})/);
const endMatch = body.match(/■返却予定日時\n(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2})/);
startTime = startMatch ? formatIcsDate(startMatch[1]) : "";
endTime = endMatch ? formatIcsDate(endMatch[1]) : "";
}
}

// 4. iCalendarデータの組み立て
const summaryPrefix = subject.includes("返却証") ? "【返却済】" : "【予約】";
const summary = `Times:${summaryPrefix}${stationName}`;
const icsDataRaw = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nando Kobo//n8n//EN
BEGIN:VEVENT
UID:times-${resId}
SUMMARY:${summary}
DTSTART:${startTime}
DTEND:${endTime}
LOCATION:${stationName}
DESCRIPTION:車両: ${carModel}\\nステーション: ${stationName}
URL:${stationUrl}
END:VEVENT
END:VCALENDAR`;

// 【修正ポイント2】改行コードを \n から \r\n (CRLF) に一括置換
const icsData = icsDataRaw.replace(/\n/g, '\r\n');

return {
json: {
action,
resId,
fileName: `times-${resId}.ics`,
icsData,
isCancel: action === "DELETE"
}
};
});

【事故トラブル用】緊急事態を視覚化


const items = $input.all();

return items.map(item =&gt; {
const body = item.json.text || item.json.textPlain || "";

// 1. 予約番号の抽出
const resIdMatch = body.match(/【予約番号】(\d+)/);
const resId = resIdMatch ? resIdMatch[1] : "000000";

// 2. ステーションと車両
const stationMatch = body.match(/【ステーション】([^\n]+)/);
const stationName = stationMatch ? stationMatch[1].trim() : "タイムズカー";

const carMatch = body.match(/【車両】([^\n]+)/);
const carModel = carMatch ? carMatch[1].trim() : "不明";

// 3. 特殊な日時パース (2026/03/20 08:00 ~ 2026/03/21 22:00)
const formatIcsDate = (str) =&gt; {
const clean = str.replace(/[\/ :]/g, '');
return clean.slice(0, 8) + 'T' + clean.slice(8, 12) + '00';
};

const timeMatch = body.match(/【予約日時】(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}) ~ (\d{4}\/\d{2}\/\d{2} \d{2}:\d{2})/);
const startTime = timeMatch ? formatIcsDate(timeMatch[1]) : "";
const endTime = timeMatch ? formatIcsDate(timeMatch[2]) : "";

// 4. iCalendarデータの組み立て
const summary = `【事故!!】${stationName}`;
const stationUrl = "https://share.timescar.jp/view/sp/reserve/list.jsp";
const description = `※車両トラブル発生中!別車両への変更が必要です。\\n車両: ${carModel}\\n予約番号: ${resId}`;

const icsData = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nando Kobo//n8n//EN
BEGIN:VEVENT
UID:times-${resId}
SUMMARY:${summary}
DTSTART:${startTime}
DTEND:${endTime}
LOCATION:${stationName}
DESCRIPTION:${description}
URL:${stationUrl}
END:VEVENT
END:VCALENDAR`.replace(/\n/g, '\r\n');

return {
json: {
action: "PUT",
resId,
fileName: `times-${resId}.ics`,
icsData,
isCancel: false
}
};
});

iCalendar規格の「潔癖さ」への対策

DAViCalへの書き込みで直面した、2つの技術的トラップへの処方箋です。

  • 日時の「T」: 20260314T100000 形式(真ん中にTが必要)を厳守。
  • 改行コード: 全ての改行を .replace(/\n/g, '\r\n’) で CRLF に変換。これが抜けるとDAViCalのDB登録でエラー(500)になります。

まとめ:APIがないなら、作ればいい

タイムズカーに公式APIはありませんが、届くメールを解析すれば、それは立派なAPIへと変貌します。

今回の構築により、私のカレンダーは「予約・事故・返却」が自動で同期される生きたログとなりました。自分の工房で、自分専用の仕組みを作る。これこそが、セルフホストの醍醐味です。