Yahoo!路線情報の検索結果をGoogleカレンダーに登録するユーザースクリプト

# 概要

Yahoo!路線情報の検索結果をGoogleカレンダーに登録するユーザースクリプトを作りました。
[GreaseyForkで公開中](https://greasyfork.org/ja/scripts/394712-yahoo-%E8%B7%AF%E7%B7%9A%E6%83%85%E5%A0%B1%E3%81%AE%E6%A4%9C%E7%B4%A2%E7%B5%90%E6%9E%9C%E3%82%92google%E3%82%AB%E3%83%AC%E3%83%B3%E3%83%80%E3%83%BC%E3%81%AB%E7%99%BB%E9%8C%B2%E3%81%99%E3%82%8B%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88)です。

# あらまし

公共交通を使った移動の予定、どのように立てていますか？

私は軽い移動ならGoogle検索で「[大波止から思案橋](https://www.google.com/search?q=%E5%A4%A7%E6%B3%A2%E6%AD%A2%E3%81%8B%E3%82%89%E6%80%9D%E6%A1%88%E6%A9%8B)」とか検索するか、GoogleアシストやYahoo!音声アシストに同じことを話しかけて済ませています。

しかし新幹線や飛行機を使って数時間かけるような重い移動では、[Yahoo!路線情報](https://transit.yahoo.co.jp/)を使って移動のルートや時間を調べた後、[Googleカレンダー](https://calendar.google.com/)に一つ一つ登録しています。
Yahoo!路線情報で調べるのは、出発地・到着地の住所を直接入力できるので最寄り駅への移動時間を含めた予定が立てやすいから。Googleカレンダーに一つ一つ登録するのは、そうしておくと道中や着いてからの予定が立てやすいのと、共有カレンダーを作ってそこに登録しておけば複数人の旅行で現地集合するときに便利だからです。

ただこの方法には便利な代わりに一つ問題があって、**カレンダーへの登録が恐ろしく面倒**です。
特に時刻を入れるのが面倒くさいんですよね。なんとか自動化できないでしょうか。しましょう。

# 手法

実は、Yahoo!路線情報には検索結果を[Yahoo!カレンダー](https://calendar.yahoo.co.jp/)に登録する機能が既にあるのですが、それをさらにGoogleカレンダーにエクスポートする機能はなく、また乗り換え含めた全ての移動を一つの予定として登録してしまいます。前項に書いたような登録をするには、自分で実装するしかなさそうです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507909/8800527e-1550-419a-abe3-e1487c6c325c.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507909/cd4a1f2b-442f-b31c-2f7c-d0f1218ba5e0.png)

Googleカレンダーに登録する処理の実装ということで、最初に思いつくのは[Calendar API](https://developers.google.com/calendar)を使うことですが、APIキーを取得して色々実装というのはちょっと面倒です。

幸いGoogleカレンダーでは、リンクをクリックするだけで予定のタイトル・時刻などを入力してくれる、予定登録用のURLを作ることができます([例](https://calendar.google.com/calendar/r/eventedit?location=%E6%B5%A6%E4%B8%8A%E8%BB%8A%E5%BA%AB&text=%E8%B7%AF%E9%9D%A2%E9%9B%BB%E8%BB%8A%E3%81%BE%E3%81%A4%E3%82%8A&dates=20191117T010000Z/20191117T070000Z))。
タイトルや時刻の入力さえ自動でやってくれれば十分なわけですから、今回はAPIを使う代わりに、[Greasemonkey](https://addons.mozilla.org/ja/firefox/addon/greasemonkey/)(今回はその互換の[Tampermonkey](https://addons.mozilla.org/ja/firefox/addon/tampermonkey/))を使って登録用のURLを自動生成し、Yahoo!路線情報の検索結果画面にそのURLへのリンクを追加する方針で行きましょう。

# 実装

## 環境

- Windows 10 Version 1903
- Firefox 71.0
- Tampermonkey v4.10.6105

## メタデータブロックの設定

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507909/cad784ff-5309-f2d0-3e7d-d1b269cec46c.png)

まずはTampermonkeyで「新規スクリプトを追加」の画面を開き、自動的に入力されている最初の数行(メタデータブロック)をいじることで、新規作成するユーザースクリプトの名前、説明文、実行するWebサイトなどの設定を行います。

```javascript
// ==UserScript==
// @name         Yahoo!路線情報の検索結果をGoogleカレンダーに登録するユーザースクリプト
// @version      0.1
// @description  Yahoo!路線情報の検索結果を、Googleカレンダーに登録するリンクを作成するユーザースクリプトです。ルート全体を一つの予定として登録するのではなく、乗り換えごとに別々の予定として登録します。登録の際、列車名を予定のタイトル、列車の出発・到着時刻を予定の開始・終了時刻、出発駅を予定の場所、発着番線を予定の説明欄に記載します。徒歩は登録しません。深夜バスなど日を跨ぐルートにも対応しています。
// @author       fukuchan
// @include      https://transit.yahoo.co.jp/search/*
// ==/UserScript==
```

## ボタンの作成

次に、Googleカレンダー登録用のボタンを作る処理を書いてみます。
また、既存の「カレンダーに登録」ボタンがそのままではややこしいので、そちらの文言は「Yahoo!カレンダーに登録」に変更しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507909/67dfde14-e45f-0500-9a80-2f5ba60ad782.png)

既存の「カレンダーに登録」ボタンには`.shareCal`というクラス名が割り振られているので、[Document.querySelectorAll](https://developer.mozilla.org/ja/docs/Web/API/Document/querySelectorAll)を使ってそのボタンを見つけては、その隣に新しいボタンを追加していけば良さそうです。

それとYahoo!路線情報では「時刻を指定しない」検索も行えるので、そういう場合にはボタンの追加は行わないことにします。

```javascript
const subscribe = event => {
    // ボタンクリック時の処理
}

// 「時刻なし」で検索している場合は何もしない
const type = new URLSearchParams(window.location.search).get("type");
if (type == 5) {
    return;
}

// 既存の「カレンダーに登録」ボタンを取得し、隣に新しいボタンを追加する
const shareCals = document.querySelectorAll(".shareCal");
for (let yahooShareCal of shareCals) {
    // 「Googleカレンダーに登録」ボタンを作成
    const li = document.createElement("li");
    const icon = yahooShareCal.closest("li").querySelector(".icnCal").cloneNode(true);
    const googleShareCal = document.createElement("a");

    // ボタンにイベントを設定
    googleShareCal.href = "javascript:void(0);";
    googleShareCal.addEventListener("click", subscribe);

    // ボタンの文言変更
    yahooShareCal.textContent = "Yahoo!カレンダーに登録";
    googleShareCal.textContent = "Googleカレンダーに登録";

    // ボタンをDOMに追加
    li.append(icon, googleShareCal);
    yahooShareCal.closest("ul").appendChild(li);
}
```

ちなみに、新しいボタンにも`.shareCal`というクラス名を割り当てたらYahoo!側のJavaScriptによってリンク先が書き替えられてしまったので注意。幸い、クラスを割り当てなくても既存のボタンと同じようなDOMの構造さえ作れば見た目は同じになりました。

## Googleカレンダーへの予定登録用URLの作成

Googleカレンダーへの予定登録用URLを生成する処理を作ります。
以下のブログが参考になりました。

- [Googleカレンダーへ予定追加するリンクのURL](http://yoshiko.hatenablog.jp/entry/2014/03/12/Google%E3%82%AB%E3%83%AC%E3%83%B3%E3%83%80%E3%83%BC%E3%81%B8%E4%BA%88%E5%AE%9A%E8%BF%BD%E5%8A%A0%E3%81%99%E3%82%8B%E3%83%AA%E3%83%B3%E3%82%AF%E3%81%AEURL)

上のブログから変えた点としては、日付のフォーマットに変換する際に[Date.prototype.toISOString()](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString)を使ったことと、URLの生成にあたって[URLSearchParams](https://developer.mozilla.org/ja/docs/Web/API/URLSearchParams)を使うようにしたことです。このほうがちょっと短く書けます。

```javascript
const location = "大阪空港(大阪モノレール)";
const text = "大阪モノレール・門真市行";
const details = "1・2番線発(乗車位置：前/中[4両編成])";
const dates = [new Date(2019, 1, 3, 20, 43), new Date(2019, 1, 3, 20, 45)]
    .map(d => d.toISOString())
    .map(d => d.replace(/(-|:|(\.\d+))/g, ""))
    .join("/");

// GoogleカレンダーのURL生成
const url = new URL("http://www.google.com/calendar/event?action=TEMPLATE");
url.searchParams.append("location", location);
url.searchParams.append("text", text);
url.searchParams.append("details", details);
url.searchParams.append("dates", dates);

// URLを開く
window.open(url.href);
```

## 予定に必要な情報を抽出

検索結果から、列車名や時刻を抽出する処理を書いていきます。

抽出したいのは以下の情報です。

|抽出する情報|例|用途|
|---|---|---|
|列車名・バス名など|大阪モノレール・門真市行|予定のタイトル|
|出発駅名|大阪空港(大阪モノレール)|予定の場所|
|出発時刻|20:43|予定の開始時刻|
|到着時刻|20:45|予定の終了時刻|
|発着番線|1・2番線発(乗車位置：前/中[4両編成]) / 1番線着|予定の詳細欄に記載|

なお、あらましの項では「最寄り駅への移動時間を含めた予定を…」とか書きましたが、カレンダーに徒歩のことまで書かれてるとさすがにしつこいので、徒歩移動についてはカレンダーに記載せず、飛ばすことにします。

そして実装ですが、ここで問題になったのが検索結果に時刻の情報は含まれていても、日付の情報は含まれていなかったことでした。(「20:43」としか書かれてない)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507909/9b50c128-3986-7110-f9a4-a10cd1a2050b.png)

幸い、ページの上の方には検索時に指定した日時が記されていたので、正規表現を使って年月日の数字を抽出することができます。ですから、少なくとも最初の出発時刻についてはこれと同じ日付と考えれば良いのですが…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507909/528d4dbb-f4c6-cd10-45a3-a4575cc74288.png)

深夜バスなどで日を跨ぐ移動となるとそうはいきません。
これについては、時刻をその出現順に沿って見て、18:40→09:19のように「時刻が巻き戻っているように見える」部分があったら日付を1日加算することにしました。

もう一つ問題になったのは、列車名と、それに対応する駅名や時刻をセットで抽出する処理が上手く書けなかったこと。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507909/dee8a879-408b-376b-e9ec-b38297824a28.png)

↑のような「改札を出ない乗り換え」をする場合に要素の入れ子関係が変化するので、どうにもスマートには書けませんでした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507909/badfabcc-2156-9495-f492-a4da4c860cc4.png)

ただ幸い、Yahoo!路線情報では直通運転の場合でも一つの列車として扱われるようです。

つまり駅や時刻の間に複数の列車名が挟まることはなく、要素は必ず「<font color="red">駅・列車名</font>・<font color="blue">駅・列車名</font>・<font color="green">駅・列車名</font>・駅…」の順番で出現します。

というわけで`.station`というクラス名が割り振られてる駅の要素と`.access`が割り振られてる列車名の要素をそれぞれ別々に取得し、単純に要素の出現順から、列車名とそれに対応する駅や時刻を抽出することにしました。
こういう実装はあまり美しくない感じがするのでもっとスマートに書けたら書きたいな。

そうして、実装は以下のようになりました。

```javascript
const subscribe = event => {
    // ルート情報の親要素を取得
    const detail = event.target.closest("div[id*=route]");

    // 時刻の各要素に年月日情報を追加する
    const searchTime = document.querySelector(".navSearchTime .time");
    const dateNumbers = searchTime.textContent.match(/(\d+)年(\d+)月(\d+)日/);
    const times = detail.querySelectorAll(".time li");
    let previousDate = new Date(dateNumbers[1], dateNumbers[2] - 1, dateNumbers[3]); // ひとつ前の時刻
    for (let time of times) {
        const timeNumbers = time.textContent.match(/(\d+):(\d+)/);
        const currentDate = new Date(previousDate.getFullYear(), previousDate.getMonth(), previousDate.getDate(), timeNumbers[1], timeNumbers[2]);

        // ひとつ前の時刻より巻き戻っているように見えたら、日を跨いでるので1日加算
        while (currentDate < previousDate) {
            currentDate.setDate(currentDate.getDate() + 1);
        }
        previousDate = currentDate;

        time.dataset.date = currentDate.toISOString().replace(/(-|:|(\.\d+))/g, "");
    }

    // 各駅と、その駅間の要素を取得
    const stations = detail.querySelectorAll(".station");
    const accesses = detail.querySelectorAll(".access");
    for (let i = 0; i < accesses.length; i++) {
        const origin = stations[i]; // 出発駅の要素
        const destination = stations[i + 1]; // 到着駅の要素
        const access = accesses[i]; // 駅間の要素

        // 歩きの場合は飛ばす
        if (access.matches(".walk")) {
            continue;
        }

        // 出発駅
        const location = origin.querySelector("dl>dt>a").textContent;

        // 列車名
        const transport = access.querySelector(".transport div");
        const text = transport.textContent.replace(/(\n|\[.*\])/g, "");

        // 発着番線
        const platform = access.querySelector(".platform");
        const details = platform ? platform.textContent : "";

        // 出発・到着時刻
        const departureTime = origin.querySelector(".time li:last-child");
        const arrivalTime = destination.querySelector(".time li:first-child");
        const dates = departureTime.dataset.date + "/" + arrivalTime.dataset.date;

        // GoogleカレンダーのURL生成
        const url = new URL("http://www.google.com/calendar/event?action=TEMPLATE");
        url.searchParams.append("location", location);
        url.searchParams.append("text", text);
        url.searchParams.append("details", details);
        url.searchParams.append("dates", dates);

        // URLを開く
        window.open(url.href);
    }
}
```

ちなみにJavaScriptの[Date](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Date)でいう「月」の値は0から始まるので注意。1月が0です。

# 最終的に出来上がったコード

[Github](https://github.com/ia15076/Userscript-which-subscribes-search-results-of-Yahoo-Transit-to-Google-Calendar)と[Greasy Fork](https://greasyfork.org/ja/scripts/394712-yahoo-%E8%B7%AF%E7%B7%9A%E6%83%85%E5%A0%B1%E3%81%AE%E6%A4%9C%E7%B4%A2%E7%B5%90%E6%9E%9C%E3%82%92google%E3%82%AB%E3%83%AC%E3%83%B3%E3%83%80%E3%83%BC%E3%81%AB%E7%99%BB%E9%8C%B2%E3%81%99%E3%82%8B%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88)で公開しています。

```javascript
// ==UserScript==
// @name         Yahoo!路線情報の検索結果をGoogleカレンダーに登録するユーザースクリプト
// @version      0.1
// @description  Yahoo!路線情報の検索結果を、Googleカレンダーに登録するリンクを作成するユーザースクリプトです。ルート全体を一つの予定として登録するのではなく、乗り換えごとに別々の予定として登録します。登録の際、列車名を予定のタイトル、列車の出発・到着時刻を予定の開始・終了時刻、出発駅を予定の場所、発着番線を予定の説明欄に記載します。徒歩は登録しません。深夜バスなど日を跨ぐルートにも対応しています。
// @author       fukuchan
// @include      https://transit.yahoo.co.jp/search/*
// ==/UserScript==

const subscribe = event => {
    // ルート情報の親要素を取得
    const detail = event.target.closest("div[id*=route]");

    // 時刻の各要素に年月日情報を追加する
    const searchTime = document.querySelector(".navSearchTime .time");
    const dateNumbers = searchTime.textContent.match(/(\d+)年(\d+)月(\d+)日/);
    const times = detail.querySelectorAll(".time li");
    let previousDate = new Date(dateNumbers[1], dateNumbers[2] - 1, dateNumbers[3]); // ひとつ前の時刻
    for (let time of times) {
        const timeNumbers = time.textContent.match(/(\d+):(\d+)/);
        const currentDate = new Date(previousDate.getFullYear(), previousDate.getMonth(), previousDate.getDate(), timeNumbers[1], timeNumbers[2]);

        // ひとつ前の時刻より巻き戻っているように見えたら、日を跨いでるので1日加算
        while (currentDate < previousDate) {
            currentDate.setDate(currentDate.getDate() + 1);
        }
        previousDate = currentDate;

        time.dataset.date = currentDate.toISOString().replace(/(-|:|(\.\d+))/g, "");
    }

    // 各駅と、その駅間の要素を取得
    const stations = detail.querySelectorAll(".station");
    const accesses = detail.querySelectorAll(".access");
    for (let i = 0; i < accesses.length; i++) {
        const origin = stations[i]; // 出発駅の要素
        const destination = stations[i + 1]; // 到着駅の要素
        const access = accesses[i]; // 駅間の要素

        // 歩きの場合は飛ばす
        if (access.matches(".walk")) {
            continue;
        }

        // 出発駅
        const location = origin.querySelector("dl>dt>a").textContent;

        // 列車名
        const transport = access.querySelector(".transport div");
        const text = transport.textContent.replace(/(\n|\[.*\])/g, "");

        // 発着番線
        const platform = access.querySelector(".platform");
        const details = platform ? platform.textContent : "";

        // 出発・到着時刻
        const departureTime = origin.querySelector(".time li:last-child");
        const arrivalTime = destination.querySelector(".time li:first-child");
        const dates = departureTime.dataset.date + "/" + arrivalTime.dataset.date;

        // GoogleカレンダーのURL生成
        const url = new URL("http://www.google.com/calendar/event?action=TEMPLATE");
        url.searchParams.append("location", location);
        url.searchParams.append("text", text);
        url.searchParams.append("details", details);
        url.searchParams.append("dates", dates);

        // URLを開く
        window.open(url.href);
    }
}

// 「時刻なし」で検索している場合は何もしない
const type = new URLSearchParams(window.location.search).get("type");
if (type == 5) {
    return;
}

// 既存の「カレンダーに登録」ボタンを取得し、隣に新しいボタンを追加する
const shareCals = document.querySelectorAll(".shareCal");
for (let yahooShareCal of shareCals) {
    // 「Googleカレンダーに登録」ボタンを作成
    const li = document.createElement("li");
    const icon = yahooShareCal.closest("li").querySelector(".icnCal").cloneNode(true);
    const googleShareCal = document.createElement("a");

    // ボタンにイベントを設定
    googleShareCal.href = "javascript:void(0);";
    googleShareCal.addEventListener("click", subscribe);

    // ボタンの文言変更
    yahooShareCal.textContent = "Yahoo!カレンダーに登録";
    googleShareCal.textContent = "Googleカレンダーに登録";

    // ボタンをDOMに追加
    li.append(icon, googleShareCal);
    yahooShareCal.closest("ul").appendChild(li);
}
```

期待通りの動作をするものが作れました。自分で言うのもなんですが、便利です。

ところでGreasy Forkでユーザースクリプトを公開するのは今回が初めての経験でした。
それでGreasy Forkに初めてユーザー登録したのですが、そのときにメールアドレスの入力を間違えてしまいました。その後に「アカウント設定」からメールアドレスを変更したのですが、そうすると「使い捨ての電子メールアドレスを使用している場合は、スクリプトを投稿することはできません。」というメッセージが出てスクリプトの投稿が出来なくなってしまいました。
アカウント削除→再作成で解決したのですが、なんかちょっと怖いな。

# 課題

見た限りではちゃんと動くものが作れましたが、課題も残ってます。

- ChromeやGreasemonkeyでの動作確認を行っていない。
    - 複数のウィンドウを同時に開く動作があるので、ブラウザによってはブロックされるかも
- タイムゾーンを考慮していない。
    - JavaScriptの[Date](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Date)には明示的にタイムゾーンを指定できないため。
    - 私の環境では勝手に日本時間と解釈してくれているっぽいが、実行環境によっては時刻が狂いそうな気がする。
- 場所の記載が曖昧
    - 「五島町」でなく「五島町駅, 日本、〒850-0035 長崎県長崎市元船町５」と記載すれば、Googleマップにも予定を出してくれてちょっと便利なのだけど出来てない。

コメントで動作報告、アドバイスなどあればぜひお寄せください。

# 余談

筆者、某所のイベントで偶然にも「チームを組んだ2人が超絶天才」という引きを決め、おこぼれの優勝景品にこんなTシャツを入手。

![ENl2exnVAAAc-DS.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507909/1164f971-9f2f-35ad-24f7-d8385805151b.jpeg)

ﾄﾞﾔｧ…
(せっかくなのでQiitaで書きたかった)(撮影ありがとうございました)

Twitter: [@fukuchancat](https://twitter.com/fukuchancat)